Compare commits
23 Commits
v1.2.10
...
v1.2.11-rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a75db5f54 | |||
| d76fadd6e8 | |||
| 4b2638caed | |||
| a6dcc64833 | |||
| a55680ed41 | |||
| 1a39e9c0e4 | |||
| 16723a04b7 | |||
| 639e25276f | |||
| ad2936bf93 | |||
| f0522ff3b7 | |||
| d6c799078c | |||
| d11dae5f85 | |||
| dd7e800ec4 | |||
| 046ad4475e | |||
| b29e08e954 | |||
| 36d193f8ec | |||
| b8f04d3595 | |||
| 5f7dd838ac | |||
| 8c9f51b74a | |||
| cef86717d9 | |||
| a97a00b7fd | |||
| f696e55600 | |||
| 36455ef479 |
@@ -8,3 +8,5 @@ node_modules
|
||||
docs
|
||||
reference
|
||||
public/datasheets/*.pdf
|
||||
.pnpm-store
|
||||
.gitea
|
||||
|
||||
@@ -13,6 +13,9 @@ on:
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
@@ -158,18 +161,6 @@ jobs:
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -180,10 +171,13 @@ jobs:
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🔒 Security Audit
|
||||
run: pnpm audit --audit-level high
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm check:spell
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
|
||||
@@ -219,8 +213,6 @@ jobs:
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v3
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v3,mode=max
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||
|
||||
@@ -392,6 +384,7 @@ jobs:
|
||||
smoke_test:
|
||||
name: 🧪 Smoke Test
|
||||
needs: [prepare, deploy]
|
||||
continue-on-error: true
|
||||
if: needs.deploy.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -403,18 +396,6 @@ jobs:
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -436,6 +417,7 @@ jobs:
|
||||
lighthouse:
|
||||
name: ⚡ Lighthouse
|
||||
needs: [prepare, deploy]
|
||||
continue-on-error: true
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -447,18 +429,6 @@ jobs:
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -469,8 +439,14 @@ jobs:
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup APT Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
@@ -510,19 +486,225 @@ jobs:
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run pagespeed:test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: WCAG Audit
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
wcag:
|
||||
name: ♿ WCAG
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
continue-on-error: true
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Puppeteer cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/puppeteer
|
||||
key: ${{ runner.os }}-puppeteer-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-puppeteer-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup APT Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# Fetch PPA key
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
|
||||
# Add PPA repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
|
||||
# Standardize binary paths
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
- name: ♿ Run WCAG Audit
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
PAGESPEED_LIMIT: 8
|
||||
run: pnpm run check:wcag
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 7: Notifications
|
||||
# JOB 8: Visual Regression Testing
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
visual_regression:
|
||||
name: 📸 Visual Diff
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
continue-on-error: true
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Puppeteer cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/puppeteer
|
||||
key: ${{ runner.os }}-puppeteer-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-puppeteer-
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup APT Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ hashFiles('package.json') }}
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# Fetch PPA key
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
|
||||
# Add PPA repository
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
|
||||
# Standardize binary paths
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
- name: 📸 Run BackstopJS Test
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
run: pnpm backstop:ci
|
||||
- name: 📤 Upload Report on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backstop-report
|
||||
path: backstop_data/html_report/
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 9: Quality Assertions
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
quality_assertions:
|
||||
name: 🛡️ Quality Gates
|
||||
needs: [prepare, deploy, smoke_test]
|
||||
continue-on-error: true
|
||||
if: success() && needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🌐 HTML DOM Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:html
|
||||
- name: 🔒 Security Headers Scan
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:security
|
||||
- name: 🔗 Lychee Deep Link Crawl
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: pnpm check:links
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 10: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, smoke_test, lighthouse]
|
||||
needs: [prepare, deploy, smoke_test, lighthouse, wcag, visual_regression, quality_assertions]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -16,4 +16,11 @@ directus/uploads
|
||||
.next-docker
|
||||
|
||||
# Pa11y CI
|
||||
.pa11yci/
|
||||
.pa11yci/
|
||||
|
||||
# BackstopJS
|
||||
backstop_data/html_report/
|
||||
backstop_data/ci_report/
|
||||
backstop_data/bitmaps_test/
|
||||
|
||||
.htmlvalidate-tmp
|
||||
22
.htmlvalidate.json
Normal file
22
.htmlvalidate.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": ["html-validate:recommended", "html-validate:document"],
|
||||
"rules": {
|
||||
"require-sri": "off",
|
||||
"meta-refresh": "off",
|
||||
"heading-level": "warn",
|
||||
"no-trailing-whitespace": "off",
|
||||
"wcag/h37": "warn",
|
||||
"no-inline-style": "off",
|
||||
"svg-focusable": "off",
|
||||
"attribute-boolean-style": "off",
|
||||
"attr-case": "off",
|
||||
"void-style": "off",
|
||||
"no-implicit-button-type": "off",
|
||||
"unique-landmark": "off",
|
||||
"long-title": "off",
|
||||
"valid-id": "off",
|
||||
"element-required-attributes": "off",
|
||||
"attribute-empty-style": "off",
|
||||
"element-permitted-content": "off"
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</p>
|
||||
<Button
|
||||
@@ -197,10 +197,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
||||
{post.frontmatter.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
|
||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
||||
{post.frontmatter.excerpt}
|
||||
</p>
|
||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
||||
|
||||
@@ -3,18 +3,15 @@ import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import SkipLink from '@/components/SkipLink';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
|
||||
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
|
||||
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
|
||||
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Suspense } from 'react';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { config } from '@/lib/config';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
@@ -128,7 +125,6 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
@@ -140,26 +136,22 @@ export default async function Layout(props: {
|
||||
</head>
|
||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||
<RecordModeProvider isEnabled={recordModeEnabled}>
|
||||
<RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</RecordModeVisuals>
|
||||
<SkipLink />
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<AnalyticsShell />
|
||||
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
|
||||
</RecordModeProvider>
|
||||
<AnalyticsShell />
|
||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
70
backstop.config.cjs
Normal file
70
backstop.config.cjs
Normal file
@@ -0,0 +1,70 @@
|
||||
/* global process, module */
|
||||
const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
|
||||
const REFERENCE_URL = process.env.REFERENCE_URL || 'https://klz-cables.com';
|
||||
|
||||
module.exports = {
|
||||
id: 'klz-cables',
|
||||
viewports: [
|
||||
{
|
||||
label: 'phone',
|
||||
width: 375,
|
||||
height: 667,
|
||||
},
|
||||
{
|
||||
label: 'tablet',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
label: 'desktop',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
},
|
||||
],
|
||||
onBeforeScript: 'puppet/onBefore.cjs',
|
||||
onReadyScript: 'puppet/onReady.cjs',
|
||||
scenarios: [
|
||||
{
|
||||
label: 'Homepage',
|
||||
url: `${BASE_URL}/`,
|
||||
referenceUrl: `${REFERENCE_URL}/`,
|
||||
readyEvent: '',
|
||||
readySelector: '',
|
||||
delay: 500,
|
||||
hideSelectors: [],
|
||||
removeSelectors: [],
|
||||
hoverSelector: '',
|
||||
clickSelector: '',
|
||||
postInteractionWait: 0,
|
||||
selectors: [],
|
||||
selectorExpansion: true,
|
||||
expect: 0,
|
||||
misMatchThreshold: 0.1,
|
||||
requireSameDimensions: true,
|
||||
},
|
||||
{
|
||||
label: '404 Error Page',
|
||||
url: `${BASE_URL}/this-page-does-not-exist`,
|
||||
referenceUrl: `${REFERENCE_URL}/this-page-does-not-exist`,
|
||||
delay: 500,
|
||||
misMatchThreshold: 0.1,
|
||||
},
|
||||
],
|
||||
paths: {
|
||||
bitmaps_reference: 'backstop_data/bitmaps_reference',
|
||||
bitmaps_test: 'backstop_data/bitmaps_test',
|
||||
engine_scripts: 'backstop_data/engine_scripts',
|
||||
html_report: 'backstop_data/html_report',
|
||||
ci_report: 'backstop_data/ci_report',
|
||||
},
|
||||
report: process.env.CI ? ['CI', 'json'] : ['browser'],
|
||||
engine: 'puppeteer',
|
||||
engineOptions: {
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
executablePath: process.env.CHROME_PATH || undefined, // Use explicit Chrome in CI
|
||||
},
|
||||
asyncCaptureLimit: 5,
|
||||
asyncCompareLimit: 50,
|
||||
debug: false,
|
||||
debugWindow: false,
|
||||
};
|
||||
26
backstop_data/engine_scripts/puppet/onBefore.cjs
Normal file
26
backstop_data/engine_scripts/puppet/onBefore.cjs
Normal file
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable */
|
||||
module.exports = async (page, scenario, vp, isReference, browserContext) => {
|
||||
console.log('onBefore: Setting up Gatekeeper Auth Cookie...');
|
||||
|
||||
// BackstopJS might be hitting localhost, testing, or staging URLs
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
// Extract domain from the scenario URL
|
||||
let targetDomain = 'localhost';
|
||||
try {
|
||||
const urlObj = new URL(scenario.url);
|
||||
targetDomain = urlObj.hostname;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Inject the ForwardAuth session cookie
|
||||
await page.setCookie({
|
||||
name: 'klz_gatekeeper_session',
|
||||
value: gatekeeperPassword,
|
||||
domain: targetDomain, // Puppeteer requires exact or matching domain for cookies
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: targetDomain !== 'localhost' && targetDomain !== 'host.docker.internal',
|
||||
});
|
||||
};
|
||||
20
backstop_data/engine_scripts/puppet/onReady.cjs
Normal file
20
backstop_data/engine_scripts/puppet/onReady.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable */
|
||||
module.exports = async (page, scenario, vp) => {
|
||||
console.log('SCENARIO > ' + scenario.label);
|
||||
|
||||
// Disable CSS animations instantly to avoid flaky screenshots
|
||||
await page.evaluate(() => {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
|
||||
// Example: Wait for fonts to load
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
};
|
||||
@@ -148,7 +148,6 @@ export default function ContactForm() {
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
onFocus={() => handleFocus('contact-name')}
|
||||
aria-label={t('form.name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -163,7 +162,6 @@ export default function ContactForm() {
|
||||
enterKeyHint="next"
|
||||
placeholder={t('form.emailPlaceholder')}
|
||||
onFocus={() => handleFocus('contact-email')}
|
||||
aria-label={t('form.email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +174,6 @@ export default function ContactForm() {
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
onFocus={() => handleFocus('contact-message')}
|
||||
aria-label={t('form.message')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
18
components/FeedbackClientWrapper.tsx
Normal file
18
components/FeedbackClientWrapper.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const FeedbackOverlay = dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface FeedbackClientWrapperProps {
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
|
||||
if (!feedbackEnabled) return null;
|
||||
|
||||
return <FeedbackOverlay />;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function Footer() {
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||
|
||||
<Container>
|
||||
<h2 className="sr-only">Footer Navigation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
||||
{/* Brand Column */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
|
||||
@@ -293,6 +293,7 @@ export default function Header() {
|
||||
aria-modal="true"
|
||||
aria-label={t('menu')}
|
||||
ref={mobileMenuRef}
|
||||
inert={isMobileMenuOpen ? undefined : true}
|
||||
>
|
||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||
{menuItems.map((item, idx) => (
|
||||
|
||||
@@ -172,7 +172,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-email')}
|
||||
placeholder={t('email')}
|
||||
aria-label={t('email')}
|
||||
className="h-9 text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +185,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
onChange={(e) => setRequest(e.target.value)}
|
||||
onFocus={() => handleFocus('quote-request')}
|
||||
placeholder={t('message')}
|
||||
aria-label={t('message')}
|
||||
className="text-xs !mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
className="fixed -top-full left-4 z-[100] px-6 py-3 bg-white text-primary-dark font-bold rounded-lg shadow-xl outline-none ring-2 ring-accent transition-all duration-300 focus:top-4"
|
||||
>
|
||||
{t('skipToContent')}
|
||||
</a>
|
||||
|
||||
@@ -10,6 +10,7 @@ import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
||||
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
||||
import { generateHeadingId, getTextContent } from '@/lib/blog';
|
||||
|
||||
export const mdxComponents = {
|
||||
VisualLinkPreview,
|
||||
@@ -36,17 +37,28 @@ export const mdxComponents = {
|
||||
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{children}</span>
|
||||
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
|
||||
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">
|
||||
(PDF)
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (href?.startsWith('/')) {
|
||||
return (
|
||||
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
|
||||
<Link
|
||||
href={href}
|
||||
{...props}
|
||||
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
@@ -61,18 +73,19 @@ export const mdxComponents = {
|
||||
>
|
||||
{children}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: (props: any) => (
|
||||
<AnimatedImage src={props.src} alt={props.alt} />
|
||||
),
|
||||
img: (props: any) => <AnimatedImage src={props.src} alt={props.alt} />,
|
||||
h2: ({ children, ...props }: any) => {
|
||||
const id = typeof children === 'string'
|
||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||
: props.id;
|
||||
const id = props.id || generateHeadingId(getTextContent(children));
|
||||
return (
|
||||
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
|
||||
{children}
|
||||
@@ -80,9 +93,7 @@ export const mdxComponents = {
|
||||
);
|
||||
},
|
||||
h3: ({ children, ...props }: any) => {
|
||||
const id = typeof children === 'string'
|
||||
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||
: props.id;
|
||||
const id = props.id || generateHeadingId(getTextContent(children));
|
||||
return (
|
||||
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
|
||||
{children}
|
||||
@@ -90,9 +101,9 @@ export const mdxComponents = {
|
||||
);
|
||||
},
|
||||
p: ({ children, ...props }: any) => (
|
||||
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||
<div {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
ul: ({ children, ...props }: any) => (
|
||||
<ul {...props} className="my-8 space-y-3">
|
||||
@@ -108,17 +119,22 @@ export const mdxComponents = {
|
||||
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
|
||||
<span className="text-primary mt-1.5 flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ children, ...props }: any) => (
|
||||
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
|
||||
<div className="text-lg text-text-primary italic">
|
||||
{children}
|
||||
</div>
|
||||
<blockquote
|
||||
{...props}
|
||||
className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg"
|
||||
>
|
||||
<div className="text-lg text-text-primary italic">{children}</div>
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children, ...props }: any) => (
|
||||
@@ -144,7 +160,10 @@ export const mdxComponents = {
|
||||
</div>
|
||||
),
|
||||
thead: ({ children, ...props }: any) => (
|
||||
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
|
||||
<thead
|
||||
{...props}
|
||||
className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200"
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function ProductCategories() {
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
|
||||
<h2 className="sr-only">{t('title')}</h2>
|
||||
{t('title') && <h2 className="sr-only">{t('title')}</h2>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
{categories.map((category, idx) => (
|
||||
<Link
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
'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);
|
||||
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
|
||||
const selector = e.data.selector;
|
||||
if (selector) {
|
||||
const el = document.querySelector(selector) as HTMLElement;
|
||||
setHoveredElement(el || null);
|
||||
} else {
|
||||
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 (!hoveredElement) return null;
|
||||
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
|
||||
// but DO show if we have a hoveredElement (from message or mouseover)
|
||||
|
||||
const rect = hoveredElement.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
|
||||
export function PlaybackCursor() {
|
||||
const { isPlaying, cursorPosition, isClicking } = 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 (
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<m.div
|
||||
className="fixed z-[10000] pointer-events-none"
|
||||
animate={{
|
||||
x: cursorPosition.x,
|
||||
y: cursorPosition.y,
|
||||
scale: isClicking ? 0.8 : 1,
|
||||
rotateX: isClicking ? 15 : 0,
|
||||
rotateY: isClicking ? -15 : 0,
|
||||
}}
|
||||
transition={{
|
||||
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
|
||||
scale: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
|
||||
}}
|
||||
style={{ perspective: '1000px' }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isClicking && (
|
||||
<m.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 2.5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Outer Pulse Ring */}
|
||||
<div
|
||||
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
|
||||
/>
|
||||
|
||||
{/* Visual Cursor */}
|
||||
<div className="relative">
|
||||
{/* Soft Glow */}
|
||||
<div
|
||||
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
|
||||
/>
|
||||
|
||||
{/* 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)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
|
||||
>
|
||||
<path
|
||||
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
|
||||
fill={isClicking ? '#82ed20' : 'white'}
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
className="transition-colors duration-150"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</m.div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
'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;
|
||||
events: RecordEvent[];
|
||||
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
|
||||
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
|
||||
removeEvent: (id: string) => void;
|
||||
clearEvents: () => void;
|
||||
setEvents: (events: RecordEvent[]) => void;
|
||||
isPlaying: boolean;
|
||||
playEvents: () => void;
|
||||
stopPlayback: () => void;
|
||||
cursorPosition: { x: number; y: number };
|
||||
zoomLevel: number;
|
||||
isBlurry: boolean;
|
||||
currentSession: RecordingSession | null;
|
||||
saveSession: (name: string) => void;
|
||||
isFeedbackActive: boolean;
|
||||
setIsFeedbackActive: (active: boolean) => void;
|
||||
reorderEvents: (startIndex: number, endIndex: number) => void;
|
||||
hoveredEventId: string | null;
|
||||
setHoveredEventId: (id: string | null) => void;
|
||||
isClicking: boolean;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const RecordModeContext = createContext<RecordModeContextType | null>(null);
|
||||
|
||||
export function useRecordMode(): RecordModeContextType {
|
||||
const context = useContext(RecordModeContext);
|
||||
if (!context) {
|
||||
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: () => {},
|
||||
reorderEvents: () => {},
|
||||
hoveredEventId: null,
|
||||
setHoveredEventId: () => {},
|
||||
setEvents: () => {},
|
||||
isClicking: false,
|
||||
isEnabled: false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function RecordModeProvider({
|
||||
children,
|
||||
isEnabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isEnabled?: boolean;
|
||||
}) {
|
||||
const [isActive, setIsActiveState] = useState(false);
|
||||
const [events, setEvents] = useState<RecordEvent[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [isBlurry, setIsBlurry] = useState(false);
|
||||
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
|
||||
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
|
||||
const [isClicking, setIsClicking] = useState(false);
|
||||
const [isEmbedded, setIsEmbedded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
|
||||
}, [isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
const embedded =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.search.includes('embedded=true') ||
|
||||
window.name === 'record-mode-iframe' ||
|
||||
window.self !== window.top);
|
||||
setIsEmbedded(embedded);
|
||||
}, [isEnabled]);
|
||||
|
||||
const setIsActive = (active: boolean) => {
|
||||
if (!isEnabled) return;
|
||||
setIsActiveState(active);
|
||||
if (active) setIsFeedbackActiveState(false);
|
||||
};
|
||||
|
||||
const setIsFeedbackActive = (active: boolean) => {
|
||||
setIsFeedbackActiveState(active);
|
||||
if (active && isEnabled) setIsActiveState(false);
|
||||
};
|
||||
|
||||
const isPlayingRef = useRef(false);
|
||||
const isLoadedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
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));
|
||||
isLoadedRef.current = true;
|
||||
}, [isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !isLoadedRef.current) return;
|
||||
localStorage.setItem('klz-record-events', JSON.stringify(events));
|
||||
}, [events, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
|
||||
}, [isActive, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (isEmbedded) {
|
||||
const handlePlaybackMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PLAY_EVENT') {
|
||||
const { event } = e.data;
|
||||
const el = event.selector
|
||||
? (document.querySelector(event.selector) as HTMLElement)
|
||||
: null;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handlePlaybackMessage);
|
||||
return () => window.removeEventListener('message', handlePlaybackMessage);
|
||||
}
|
||||
}, [isEmbedded, isEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isEmbedded || !isActive) return;
|
||||
const event = events.find((e) => e.id === hoveredEventId);
|
||||
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
|
||||
|
||||
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
|
||||
if (!isEnabled) return;
|
||||
const newEvent: RecordEvent = {
|
||||
realClick: false,
|
||||
...event,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setEvents((prev) => [...prev, newEvent]);
|
||||
};
|
||||
|
||||
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
|
||||
if (!isEnabled) return;
|
||||
setEvents((prev) =>
|
||||
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
|
||||
);
|
||||
};
|
||||
|
||||
const reorderEvents = (startIndex: number, endIndex: number) => {
|
||||
if (!isEnabled) return;
|
||||
const result = Array.from(events);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
setEvents(result);
|
||||
};
|
||||
|
||||
const removeEvent = (id: string) => {
|
||||
if (!isEnabled) return;
|
||||
setEvents((prev) => prev.filter((event) => event.id !== id));
|
||||
};
|
||||
|
||||
const clearEvents = () => {
|
||||
if (!isEnabled) return;
|
||||
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) => {
|
||||
if (!isEnabled) return;
|
||||
console.log('Saving session:', name, events);
|
||||
};
|
||||
|
||||
const playEvents = async () => {
|
||||
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
|
||||
setIsPlaying(true);
|
||||
isPlayingRef.current = true;
|
||||
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (!isPlayingRef.current) break;
|
||||
if (event.rect && !isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
const iframeRect = iframe?.getBoundingClientRect();
|
||||
setCursorPosition({
|
||||
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
|
||||
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.selector) {
|
||||
if (!isEmbedded) {
|
||||
const iframe = document.querySelector(
|
||||
'iframe[name="record-mode-iframe"]',
|
||||
) as HTMLIFrameElement;
|
||||
if (iframe?.contentWindow)
|
||||
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
|
||||
} else {
|
||||
const el = document.querySelector(event.selector) as HTMLElement;
|
||||
if (el) {
|
||||
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
else if (event.type === 'mouse') {
|
||||
const currentRect = el.getBoundingClientRect();
|
||||
let targetX = currentRect.left + currentRect.width / 2;
|
||||
let targetY = currentRect.top + currentRect.height / 2;
|
||||
|
||||
if (event.clickOrigin === 'top-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'top-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.top + 5;
|
||||
} else if (event.clickOrigin === 'bottom-left') {
|
||||
targetX = currentRect.left + 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
} else if (event.clickOrigin === 'bottom-right') {
|
||||
targetX = currentRect.right - 5;
|
||||
targetY = currentRect.bottom - 5;
|
||||
}
|
||||
|
||||
const eventCoords = { clientX: targetX, clientY: targetY };
|
||||
const dispatchMouse = (type: string) => {
|
||||
el.dispatchEvent(
|
||||
new MouseEvent(type, {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...eventCoords,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (event.interactionType === 'click') {
|
||||
setIsClicking(true);
|
||||
dispatchMouse('mousedown');
|
||||
setTimeout(() => {
|
||||
dispatchMouse('mouseup');
|
||||
if (event.realClick) {
|
||||
dispatchMouse('click');
|
||||
el.click();
|
||||
}
|
||||
setIsClicking(false);
|
||||
}, 150);
|
||||
} else {
|
||||
dispatchMouse('mousemove');
|
||||
dispatchMouse('mouseover');
|
||||
dispatchMouse('mouseenter');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.zoom) setZoomLevel(event.zoom);
|
||||
if (event.motionBlur) setIsBlurry(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
|
||||
setIsBlurry(false);
|
||||
}
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
};
|
||||
|
||||
const stopPlayback = () => {
|
||||
setIsPlaying(false);
|
||||
isPlayingRef.current = false;
|
||||
setZoomLevel(1);
|
||||
setIsBlurry(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecordModeContext.Provider
|
||||
value={{
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
clearEvents,
|
||||
setEvents,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
stopPlayback,
|
||||
cursorPosition,
|
||||
zoomLevel,
|
||||
isBlurry,
|
||||
currentSession,
|
||||
saveSession,
|
||||
isFeedbackActive,
|
||||
setIsFeedbackActive,
|
||||
reorderEvents,
|
||||
hoveredEventId,
|
||||
setHoveredEventId,
|
||||
isClicking,
|
||||
isEnabled,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordModeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import { Reorder, AnimatePresence, LazyMotion } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
MousePointer2,
|
||||
Scroll,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
Eye,
|
||||
Edit2,
|
||||
X,
|
||||
Check,
|
||||
Download,
|
||||
Settings2,
|
||||
GripVertical,
|
||||
Clock,
|
||||
Maximize2,
|
||||
Box,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { RecordEvent } from '@/types/record-mode';
|
||||
import { PlaybackCursor } from './PlaybackCursor';
|
||||
|
||||
export function RecordModeOverlay() {
|
||||
const {
|
||||
isActive,
|
||||
setIsActive,
|
||||
events,
|
||||
addEvent,
|
||||
updateEvent,
|
||||
removeEvent,
|
||||
isPlaying,
|
||||
playEvents,
|
||||
saveSession,
|
||||
clearEvents,
|
||||
reorderEvents,
|
||||
setHoveredEventId,
|
||||
setEvents, // Added setEvents here
|
||||
} = useRecordMode();
|
||||
|
||||
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
|
||||
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !isActive) return;
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'ELEMENT_SELECTED') {
|
||||
const { selector, rect, tagName } = e.data;
|
||||
|
||||
if (pickingMode === 'mouse') {
|
||||
addEvent({
|
||||
type: 'mouse',
|
||||
interactionType: lastInteractionType,
|
||||
selector,
|
||||
duration: lastInteractionType === 'click' ? 1000 : 1500,
|
||||
zoom: 1,
|
||||
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
|
||||
motionBlur: false,
|
||||
realClick: false,
|
||||
rect,
|
||||
});
|
||||
} else if (pickingMode === 'scroll') {
|
||||
addEvent({
|
||||
type: 'scroll',
|
||||
selector,
|
||||
duration: 1500,
|
||||
zoom: 1,
|
||||
description: `Scroll to ${tagName}`,
|
||||
motionBlur: false,
|
||||
rect,
|
||||
});
|
||||
}
|
||||
setPickingMode(null);
|
||||
} else if (e.data.type === 'PICKING_CANCELLED') {
|
||||
setPickingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
if (pickingMode) {
|
||||
// Find the iframe and signal start picking
|
||||
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 () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [isActive, pickingMode, addEvent, mounted]);
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingEventId) {
|
||||
updateEvent(editingEventId, editForm);
|
||||
setEditingEventId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [showEvents, setShowEvents] = useState(true);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={() => setIsActive(true)}
|
||||
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-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
||||
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
|
||||
{/* 1. Global Toolbar - Slim Industrial Bar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<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">
|
||||
{/* Identity Tag */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
|
||||
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
|
||||
Event Builder
|
||||
</span>
|
||||
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
|
||||
Manual Mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Action Tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode('mouse');
|
||||
setLastInteractionType('click');
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
<span>Mouse</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPickingMode('scroll')}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Scroll size={16} />
|
||||
<span>Scroll</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
addEvent({
|
||||
type: 'wait',
|
||||
duration: 2000,
|
||||
zoom: 1,
|
||||
description: 'Wait for 2s',
|
||||
motionBlur: false,
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
{/* Sequence Controls */}
|
||||
<div className="flex items-center gap-1 p-0.5">
|
||||
<button
|
||||
onClick={playEvents}
|
||||
disabled={isPlaying || events.length === 0}
|
||||
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
|
||||
title="Preview Sequence"
|
||||
>
|
||||
<Play size={18} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
{events.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
|
||||
{events.length}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 2. Event Timeline Popover */}
|
||||
{showEvents && (
|
||||
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
|
||||
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
|
||||
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
|
||||
{events.length} Actions Recorded
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
disabled={events.length === 0}
|
||||
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={events}
|
||||
onReorder={setEvents}
|
||||
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-white/10">
|
||||
<Plus size={40} strokeWidth={1} />
|
||||
<p className="text-xs mt-4">Timeline is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event, index) => (
|
||||
<Reorder.Item
|
||||
key={event.id}
|
||||
value={event}
|
||||
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId(null)}
|
||||
>
|
||||
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white text-[10px] font-black uppercase tracking-widest">
|
||||
{event.type === 'mouse'
|
||||
? `Mouse (${event.interactionType})`
|
||||
: event.type}
|
||||
</span>
|
||||
{event.clickOrigin &&
|
||||
event.clickOrigin !== 'center' &&
|
||||
event.interactionType === 'click' && (
|
||||
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
|
||||
{event.clickOrigin}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
|
||||
{event.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
|
||||
{event.selector || 'system:wait'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingEventId(event.id);
|
||||
setEditForm(event);
|
||||
}}
|
||||
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeEvent(event.id)}
|
||||
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))
|
||||
)}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
|
||||
|
||||
{/* Picking Tooltip */}
|
||||
{pickingMode && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
|
||||
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
|
||||
<span className="font-black uppercase tracking-widest text-xs">
|
||||
Assigning {pickingMode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-primary-dark/20" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setPickingMode(null);
|
||||
setHoveredElement(null);
|
||||
}}
|
||||
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
|
||||
>
|
||||
ESC to Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlaybackCursor />
|
||||
|
||||
{/* 3. Event Options Panel (Sidebar-like) */}
|
||||
<AnimatePresence>
|
||||
{editingEventId && (
|
||||
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
|
||||
Event Options
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setEditingEventId(null)}
|
||||
className="p-2 text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
|
||||
{/* Type Display */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Interaction Type
|
||||
</label>
|
||||
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
type: 'mouse',
|
||||
interactionType: 'click',
|
||||
}))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<MousePointer2 size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Click</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
type: 'mouse',
|
||||
interactionType: 'hover',
|
||||
}))
|
||||
}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Hover</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Scroll size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Scroll</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
|
||||
>
|
||||
<Clock size={14} />
|
||||
<span className="text-[10px] font-black uppercase">Wait</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Precise Click Origin */}
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
|
||||
Click Origin
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
|
||||
{[
|
||||
{ id: 'top-left', label: 'TL' },
|
||||
{ id: 'top-right', label: 'TR' },
|
||||
{ id: 'center', label: 'CTR' },
|
||||
{ id: 'bottom-left', label: 'BL' },
|
||||
{ id: 'bottom-right', label: 'BR' },
|
||||
].map((origin) => (
|
||||
<button
|
||||
key={origin.id}
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
|
||||
}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
|
||||
>
|
||||
{origin.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
|
||||
<span>Timeline Allocation</span>
|
||||
<span className="text-accent">{editForm.duration}ms</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={editForm.duration || 1000}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
|
||||
}
|
||||
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom & Effects */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<Maximize2 size={18} className="text-white/40" />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Zoom Shift
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="1"
|
||||
max="3"
|
||||
value={editForm.zoom || 1}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
|
||||
}
|
||||
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
|
||||
}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Box size={18} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Motion Blur
|
||||
</span>
|
||||
</div>
|
||||
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
|
||||
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
|
||||
}
|
||||
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExternalLink size={18} />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs font-bold uppercase tracking-wider">
|
||||
Trigger Navigation
|
||||
</span>
|
||||
<span className="text-[8px] opacity-60">
|
||||
Allows URL transitions in Studio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
|
||||
>
|
||||
Commit Changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
'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(() => {
|
||||
// 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());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Recursion Guard: If we are already in an embedded iframe,
|
||||
// strictly return just the children to prevent Inception.
|
||||
// Note: This causes a hydration mismatch remount ONLY when actually embedded (e.g. inside Directus).
|
||||
// Standard users and Lighthouse bots will NOT suffer a remount.
|
||||
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
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@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; }
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecordMode } from './RecordModeContext';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const FeedbackOverlay = dynamic(
|
||||
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const RecordModeOverlay = dynamic(
|
||||
() => import('./RecordModeOverlay').then((mod) => mod.RecordModeOverlay),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { PickingHelper } from './PickingHelper';
|
||||
|
||||
interface ToolCoordinatorProps {
|
||||
isEmbedded?: boolean;
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCoordinator({
|
||||
isEmbedded: isEmbeddedProp,
|
||||
feedbackEnabled = false,
|
||||
}: ToolCoordinatorProps) {
|
||||
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
|
||||
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;
|
||||
|
||||
// Nothing enabled → render nothing
|
||||
if (!feedbackEnabled && !isEnabled) return null;
|
||||
|
||||
// Iframe → only PickingHelper
|
||||
if (isEmbedded) return <PickingHelper />;
|
||||
|
||||
// Record Mode active and enabled
|
||||
if (isActive && isEnabled) return <RecordModeOverlay />;
|
||||
|
||||
// Feedback active and enabled
|
||||
if (isFeedbackActive && feedbackEnabled) {
|
||||
return (
|
||||
<FeedbackOverlay
|
||||
isActive={isFeedbackActive}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Baseline: toggle buttons
|
||||
return (
|
||||
<div className="feedback-ui-ignore">
|
||||
{feedbackEnabled && (
|
||||
<FeedbackOverlay
|
||||
isActive={false}
|
||||
onActiveChange={(active) => setIsFeedbackActive(active)}
|
||||
/>
|
||||
)}
|
||||
{isEnabled && <RecordModeOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
cspell.json
Normal file
97
cspell.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"version": "0.2",
|
||||
"language": "en,de",
|
||||
"dictionaries": ["de-de", "html", "css", "typescript", "npm"],
|
||||
"words": [
|
||||
"Datasheet",
|
||||
"datasheets",
|
||||
"Bodemer",
|
||||
"Mintel",
|
||||
"Umami",
|
||||
"Energiezukunft",
|
||||
"Energiewende",
|
||||
"Solarparks",
|
||||
"Energiekabel",
|
||||
"Kabelinfrastruktur",
|
||||
"Großprojekte",
|
||||
"Zertifizierte",
|
||||
"Erstberatung",
|
||||
"Vertriebs",
|
||||
"Windparkbau",
|
||||
"Kabelherausforderungen",
|
||||
"Energieprojekt",
|
||||
"mittelspannungskabel",
|
||||
"niederspannungskabel",
|
||||
"hochspannungskabel",
|
||||
"solarkabel",
|
||||
"extralight",
|
||||
"medv",
|
||||
"Crect",
|
||||
"Csvg",
|
||||
"mintel",
|
||||
"Zurück",
|
||||
"Übersicht",
|
||||
"Raiffeisenstraße",
|
||||
"Remshalden",
|
||||
"Experte",
|
||||
"hochwertige",
|
||||
"Stromkabel",
|
||||
"Mittelspannungslösungen",
|
||||
"Zuverlässige",
|
||||
"Infrastruktur",
|
||||
"eine",
|
||||
"grüne",
|
||||
"Weiterer",
|
||||
"Artikel",
|
||||
"Vorheriger",
|
||||
"Beitrag",
|
||||
"Nächster",
|
||||
"Lösungen",
|
||||
"Bereit",
|
||||
"Planung",
|
||||
"Lieferung",
|
||||
"hochwertiger",
|
||||
"erwecken",
|
||||
"Ihre",
|
||||
"Projekte",
|
||||
"Leben",
|
||||
"Strategischer",
|
||||
"schnelle",
|
||||
"Nachhaltige",
|
||||
"Expertenberatung",
|
||||
"Qualität",
|
||||
"nach",
|
||||
"Projekt",
|
||||
"anfragen",
|
||||
"Kostenlose",
|
||||
"Vorhaben",
|
||||
"kopiert",
|
||||
"Teilen",
|
||||
"Inhalt",
|
||||
"produkte",
|
||||
"Fokus",
|
||||
"drei",
|
||||
"typische",
|
||||
"fokus",
|
||||
"Warum",
|
||||
"ideale",
|
||||
"Kabel",
|
||||
"Deutsch",
|
||||
"Spannung",
|
||||
"unbekannt"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"public",
|
||||
"pnpm-lock.yaml",
|
||||
"*.svg",
|
||||
"*.mp4",
|
||||
"directus",
|
||||
"backstop_data",
|
||||
".gitea",
|
||||
"out",
|
||||
"coverage",
|
||||
"*.json"
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,6 @@ category: Kabel Technologie
|
||||
Kabeltrommeln spielen eine essenzielle Rolle in der Windkraftbranche – sie ermöglichen den sicheren Transport und die Lagerung von Stromkabeln. Doch was geschieht mit ihnen, wenn die Kabel verlegt sind? Jährlich fallen unzählige Trommeln an, die entweder entsorgt oder einer sinnvollen Wiederverwendung zugeführt werden müssen.
|
||||
Ohne ein durchdachtes Recyclingkonzept würden enorme Mengen an Holz, Stahl und Kunststoff ungenutzt bleiben. Dabei gibt es längst effiziente Lösungen, um Kabeltrommeln in den Rohstoffkreislauf zurückzuführen und die Umweltbelastung zu minimieren.
|
||||
<hr />
|
||||
##
|
||||
### Materialien und ihre Wiederverwertung
|
||||
Kabeltrommeln bestehen aus unterschiedlichen Materialien, die jeweils verschiedene Recyclingmöglichkeiten bieten. Eine gezielte Rückführung hängt davon ab, ob das Material wiederverwertet oder weiterverarbeitet werden kann.
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ Ein Pluspunkt des H1Z2Z2-K ist seine Eignung zur direkten Erdverlegung – ohne
|
||||
|
||||
**Wichtig:** Für Projekte ab mehreren hundert Metern lohnt sich eine Spannungsfallberechnung – 6mm² ist nicht immer automatisch die optimale Wahl.
|
||||
<hr />
|
||||
##
|
||||
## FAQ: Die häufigsten Fragen rund um H1Z2Z2-K Solarkabel
|
||||
**Was bedeutet H1Z2Z2-K?**<br />Die Bezeichnung steht für einen Kabeltyp mit bestimmten Isoliermaterialien und Eigenschaften laut EN 50618, geeignet für DC-Strom bis 1500 V.
|
||||
**Ist das Kabel für Erdverlegung zugelassen?**<br />Ja, inklusive direkter Erdverlegung ohne zusätzliche Schutzrohre.
|
||||
|
||||
@@ -94,7 +94,6 @@ One major advantage of the H1Z2Z2-K is its suitability for direct burial – wit
|
||||
|
||||
**Important:** For projects spanning several hundred meters, a voltage drop calculation is worthwhile – 6mm² isn’t always the best fit by default.
|
||||
<hr />
|
||||
##
|
||||
## FAQ: The most frequently asked questions about H1Z2Z2-K solar cables
|
||||
**What does H1Z2Z2-K mean?**<br />This designation refers to a cable type with specific insulation materials and properties according to EN 50618, suitable for DC voltage up to 1500 V.
|
||||
**Is the cable approved for underground installation?**<br />Yes, including direct burial without additional protective conduits.
|
||||
|
||||
@@ -18,6 +18,7 @@ export default [
|
||||
"**/.git/**",
|
||||
"*.js",
|
||||
"*.mjs",
|
||||
"*.cjs",
|
||||
"scripts/**",
|
||||
"tests/**",
|
||||
"next-env.d.ts",
|
||||
|
||||
86
lib/blog.ts
86
lib/blog.ts
@@ -4,6 +4,31 @@ import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export function extractExcerpt(content: string): string {
|
||||
if (!content) return '';
|
||||
// Remove frontmatter if present (though matter() usually strips it out)
|
||||
let text = content.replace(/^---[\s\S]*?---/, '');
|
||||
// Remove MDX component imports and usages
|
||||
text = text.replace(/<[^>]+>/g, '');
|
||||
text = text.replace(/^[ \t]*import\s+.*$/gm, '');
|
||||
text = text.replace(/^[ \t]*export\s+.*$/gm, '');
|
||||
// Remove markdown headings
|
||||
text = text.replace(/^#+.*$/gm, '');
|
||||
// Extract first paragraph or combined lines
|
||||
const paragraphs = text
|
||||
.split(/\n\s*\n/)
|
||||
.filter((p) => p.trim() && !p.trim().startsWith('---') && !p.trim().startsWith('#'));
|
||||
if (paragraphs.length === 0) return '';
|
||||
|
||||
const excerpt = paragraphs[0]
|
||||
.replace(/[*_`]/g, '') // remove markdown bold/italic/code
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // replace links with their text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return excerpt.length > 200 ? excerpt.slice(0, 197) + '...' : excerpt;
|
||||
}
|
||||
|
||||
export interface PostFrontmatter {
|
||||
title: string;
|
||||
date: string;
|
||||
@@ -46,7 +71,10 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
||||
|
||||
const postInfo = {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PostFrontmatter,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
};
|
||||
|
||||
@@ -70,7 +98,10 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: data as PostFrontmatter,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
};
|
||||
})
|
||||
@@ -95,7 +126,10 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: data as PostFrontmatter,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(fileContent.replace(/^---[\s\S]*?---/, '')),
|
||||
} as PostFrontmatter,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
@@ -109,7 +143,12 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
||||
export async function getAdjacentPosts(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<{ prev: PostMdx | null; next: PostMdx | null; isPrevRandom?: boolean; isNextRandom?: boolean }> {
|
||||
): Promise<{
|
||||
prev: PostMdx | null;
|
||||
next: PostMdx | null;
|
||||
isPrevRandom?: boolean;
|
||||
isNextRandom?: boolean;
|
||||
}> {
|
||||
const posts = await getAllPosts(locale);
|
||||
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||
|
||||
@@ -127,7 +166,7 @@ export async function getAdjacentPosts(
|
||||
let isPrevRandom = false;
|
||||
|
||||
const getRandomPost = (excludeSlugs: string[]) => {
|
||||
const available = posts.filter(p => !excludeSlugs.includes(p.slug));
|
||||
const available = posts.filter((p) => !excludeSlugs.includes(p.slug));
|
||||
if (available.length === 0) return null;
|
||||
return available[Math.floor(Math.random() * available.length)];
|
||||
};
|
||||
@@ -154,17 +193,42 @@ export function getReadingTime(content: string): number {
|
||||
return Math.ceil(minutes);
|
||||
}
|
||||
|
||||
export function generateHeadingId(text: string): string {
|
||||
let id = text.toLowerCase();
|
||||
id = id.replace(/ä/g, 'ae');
|
||||
id = id.replace(/ö/g, 'oe');
|
||||
id = id.replace(/ü/g, 'ue');
|
||||
id = id.replace(/ß/g, 'ss');
|
||||
|
||||
id = id.replace(/[*_`]/g, '');
|
||||
id = id.replace(/[^\w\s-]/g, '');
|
||||
id = id
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return id || 'heading';
|
||||
}
|
||||
|
||||
export function getTextContent(node: any): string {
|
||||
if (typeof node === 'string') return node;
|
||||
if (typeof node === 'number') return node.toString();
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join('');
|
||||
if (node && typeof node === 'object' && node.props && node.props.children) {
|
||||
return getTextContent(node.props.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getHeadings(content: string): { id: string; text: string; level: number }[] {
|
||||
const headingLines = content.split('\n').filter((line) => line.match(/^#{2,3}\s/));
|
||||
|
||||
return headingLines.map((line) => {
|
||||
const level = line.match(/^#+/)?.[0].length || 0;
|
||||
const text = line.replace(/^#+\s/, '').trim();
|
||||
const id = text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
const rawText = line.replace(/^#+\s/, '').trim();
|
||||
const cleanText = rawText.replace(/[*_`]/g, '');
|
||||
const id = generateHeadingId(cleanText);
|
||||
|
||||
return { id, text, level };
|
||||
return { id, text: cleanText, level };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ function createConfig() {
|
||||
|
||||
console.log('[Config] Initializing Toggles:', {
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -28,7 +27,6 @@ function createConfig() {
|
||||
isTesting: target === 'testing',
|
||||
isDevelopment: target === 'development',
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
|
||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||
|
||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||
@@ -150,9 +148,6 @@ export const config = {
|
||||
get feedbackEnabled() {
|
||||
return getConfig().feedbackEnabled;
|
||||
},
|
||||
get recordModeEnabled() {
|
||||
return getConfig().recordModeEnabled;
|
||||
},
|
||||
get infraCMS() {
|
||||
return getConfig().infraCMS;
|
||||
},
|
||||
|
||||
162
lib/mdx.ts
162
lib/mdx.ts
@@ -26,42 +26,43 @@ export async function getProductMetadata(
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
// Try exact slug first
|
||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Try with -2 suffix (common in the dumped files)
|
||||
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: true,
|
||||
} as ProductFrontmatter & { isFallback?: boolean },
|
||||
};
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
};
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: filePath.includes('/en/'),
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,68 +74,83 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
// Try exact slug first
|
||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Try with -2 suffix (common in the dumped files)
|
||||
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
let product: ProductMdx | null = null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: true,
|
||||
} as ProductFrontmatter & { isFallback?: boolean },
|
||||
content,
|
||||
};
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
};
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
let isFallback = false;
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
if (filePath) isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
product = {
|
||||
const product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback,
|
||||
} as any,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out products without images
|
||||
if (
|
||||
product &&
|
||||
(!product.frontmatter.images ||
|
||||
// Filter out products without images
|
||||
if (
|
||||
!product.frontmatter.images ||
|
||||
product.frontmatter.images.length === 0 ||
|
||||
!product.frontmatter.images[0])
|
||||
) {
|
||||
return null;
|
||||
!product.frontmatter.images[0]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
return product;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(productsDir);
|
||||
return files.filter((file) => file.endsWith('.mdx')).map((file) => file.replace(/\.mdx$/, ''));
|
||||
const slugs: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
slugs.push(file.replace(/\.mdx$/, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(productsDir);
|
||||
return slugs;
|
||||
}
|
||||
|
||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
|
||||
@@ -348,6 +348,10 @@ const nextConfig = {
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/de/produkte',
|
||||
destination: '/de/products',
|
||||
},
|
||||
{
|
||||
source: '/cms/:path*',
|
||||
destination: `${directusUrl}/:path*`,
|
||||
|
||||
34
package.json
34
package.json
@@ -4,14 +4,13 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@medv/finder": "^4.0.2",
|
||||
"@mintel/mail": "1.8.3",
|
||||
"@mintel/next-config": "1.8.3",
|
||||
"@mintel/next-feedback": "1.8.10",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"@sentry/nextjs": "^10.39.0",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -40,20 +39,17 @@
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.0",
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@cspell/dict-de-de": "^4.1.2",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/eslint-config": "1.8.3",
|
||||
"@mintel/tsconfig": "1.8.3",
|
||||
"@next/bundle-analyzer": "^16.1.6",
|
||||
"@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/postcss": "^4.1.18",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
@@ -66,10 +62,13 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"backstopjs": "^6.3.25",
|
||||
"cheerio": "^1.2.0",
|
||||
"critters": "^0.0.25",
|
||||
"cspell": "^9.6.4",
|
||||
"eslint": "^9.18.0",
|
||||
"happy-dom": "^20.6.1",
|
||||
"html-validate": "^10.8.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
@@ -77,7 +76,6 @@
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.37.3",
|
||||
"remotion": "^4.0.421",
|
||||
"sass": "^1.97.1",
|
||||
"start-server-and-test": "^2.1.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -99,6 +97,14 @@
|
||||
"check:mdx": "node scripts/validate-mdx.mjs",
|
||||
"check:a11y": "pa11y-ci",
|
||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||
"check:html": "tsx ./scripts/check-html.ts",
|
||||
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
|
||||
"check:security": "tsx ./scripts/check-security.ts",
|
||||
"check:links": "bash ./scripts/check-links.sh",
|
||||
"backstop:reference": "backstop reference --config=backstop.config.cjs --docker",
|
||||
"backstop:test": "backstop test --config=backstop.config.cjs --docker",
|
||||
"backstop:approve": "backstop approve --config=backstop.config.cjs --docker",
|
||||
"backstop:ci": "backstop test --config=backstop.config.cjs",
|
||||
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
||||
@@ -120,15 +126,14 @@
|
||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||
"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",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6"
|
||||
"next": "16.1.6",
|
||||
"minimatch": ">=10.2.2"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
@@ -137,11 +142,6 @@
|
||||
"not ie 11"
|
||||
],
|
||||
"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"
|
||||
"lucide-react": "^0.563.0"
|
||||
}
|
||||
}
|
||||
|
||||
2835
pnpm-lock.yaml
generated
2835
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
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();
|
||||
|
||||
const sortedEvents = useMemo(() => {
|
||||
if (!session) return [];
|
||||
return [...session.events].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [session]);
|
||||
|
||||
if (!session || !session.events.length) {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
No session data found.
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
87
scripts/check-html.ts
Normal file
87
scripts/check-html.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 0; // 0 means no limit
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
|
||||
console.log(`📊 Limit: ${limit ? limit : 'None (Full Sitemap)'} pages\n`);
|
||||
|
||||
try {
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
|
||||
const response = await axios.get(sitemapUrl, {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
validateStatus: (status) => status < 400,
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||
let urls = $('url loc')
|
||||
.map((i, el) => $(el).text())
|
||||
.get();
|
||||
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith('http'))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.error('❌ No URLs found in sitemap. Is the site up?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (limit && urls.length > limit) {
|
||||
console.log(
|
||||
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
|
||||
);
|
||||
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
|
||||
const others = urls.filter((u) => !home.includes(u));
|
||||
urls = [...home, ...others.slice(0, limit - home.length)];
|
||||
}
|
||||
|
||||
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
|
||||
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
console.log(`📥 Fetching HTML for ${urls.length} pages...`);
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const u = urls[i];
|
||||
try {
|
||||
const res = await axios.get(u, {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
});
|
||||
const filename = `page-${i}.html`;
|
||||
fs.writeFileSync(path.join(outputDir, filename), res.data);
|
||||
} catch (err: any) {
|
||||
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n💻 Executing html-validate...`);
|
||||
try {
|
||||
execSync(`npx html-validate .htmlvalidate-tmp/*.html`, { stdio: 'inherit' });
|
||||
console.log(`✅ HTML Validation passed perfectly!`);
|
||||
} catch (e) {
|
||||
console.error(`❌ HTML Validation found issues.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`\n❌ Error during HTML Validation:`, error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
|
||||
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
26
scripts/check-links.sh
Normal file
26
scripts/check-links.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Auto-provision Lychee Rust Binary if missing
|
||||
if [ ! -f ./lychee ]; then
|
||||
echo "📥 Downloading Lychee Link Checker (v0.15.1)..."
|
||||
curl -sSLo lychee.tar.gz https://github.com/lycheeverse/lychee/releases/download/v0.15.1/lychee-v0.15.1-x86_64-unknown-linux-gnu.tar.gz
|
||||
tar -xzf lychee.tar.gz lychee
|
||||
rm lychee.tar.gz
|
||||
chmod +x ./lychee
|
||||
fi
|
||||
|
||||
echo "🚀 Starting Deep Link Assessment (Lychee)..."
|
||||
|
||||
# Exclude localhost, mintel.me (internal infrastructure), and common placeholder domains
|
||||
# Scan markdown files and component files for hardcoded dead links
|
||||
./lychee \
|
||||
--exclude "localhost" \
|
||||
--exclude "127.0.0.1" \
|
||||
--exclude "mintel\.me" \
|
||||
--exclude "example\.com" \
|
||||
--exclude-mail \
|
||||
--accept 200,204,401,403 \
|
||||
"./content/**/*.mdx" "./content/**/*.md" "./app/**/*.tsx" "./components/**/*.tsx"
|
||||
|
||||
echo "✅ All project source links are alive and healthy!"
|
||||
54
scripts/check-security.ts
Normal file
54
scripts/check-security.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
const requiredHeaders = [
|
||||
'strict-transport-security',
|
||||
'x-frame-options',
|
||||
'x-content-type-options',
|
||||
'referrer-policy',
|
||||
'content-security-policy',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🛡️ Starting Security Headers Scan for: ${targetUrl}\n`);
|
||||
try {
|
||||
const response = await axios.head(targetUrl, {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
const headers = response.headers;
|
||||
let allPassed = true;
|
||||
|
||||
const results = requiredHeaders.map((header) => {
|
||||
const present = !!headers[header];
|
||||
if (!present) allPassed = false;
|
||||
return {
|
||||
Header: header,
|
||||
Status: present ? '✅ Present' : '❌ Missing',
|
||||
Value: present
|
||||
? headers[header].length > 50
|
||||
? headers[header].substring(0, 47) + '...'
|
||||
: headers[header]
|
||||
: 'N/A',
|
||||
};
|
||||
});
|
||||
|
||||
console.table(results);
|
||||
|
||||
if (allPassed) {
|
||||
console.log(`\n✅ All required security headers are correctly configured!\n`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`\n❌ Missing critical security headers. Please update next.config.mjs!\n`);
|
||||
process.exit(process.env.CI ? 1 : 0); // Don't crash local dev hard if missing, but crash CI
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Failed to scan headers: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -2,9 +2,7 @@ const fs = require('fs');
|
||||
const files = [
|
||||
'/Users/marcmintel/Projects/klz-2026/components/Header.tsx',
|
||||
'/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx',
|
||||
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx',
|
||||
'/Users/marcmintel/Projects/klz-2026/components/record-mode/RecordModeOverlay.tsx',
|
||||
'/Users/marcmintel/Projects/klz-2026/components/record-mode/PlaybackCursor.tsx'
|
||||
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx'
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
@@ -84,6 +84,7 @@ async function main() {
|
||||
ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'],
|
||||
chromeLaunchConfig: {
|
||||
...baseConfig.defaults?.chromeLaunchConfig,
|
||||
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
|
||||
args: [
|
||||
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
|
||||
'--no-sandbox',
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface RecordEvent {
|
||||
id: string;
|
||||
type: 'mouse' | 'scroll' | 'wait';
|
||||
interactionType?: 'click' | 'hover';
|
||||
selector?: string; // CSS selector
|
||||
timestamp: number; // Time in ms since start of recording
|
||||
duration: number; // Duration allocated for this action in playback
|
||||
zoom?: number; // Zoom level during event
|
||||
description?: string; // Optional label
|
||||
motionBlur?: boolean; // Enable motion blur effect
|
||||
rect?: { x: number; y: number; width: number; height: number }; // Element position for rendering
|
||||
clickOrigin?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
realClick?: boolean; // Trigger real browser action (navigation)
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
id: string;
|
||||
name: string;
|
||||
events: RecordEvent[];
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user