Compare commits

...

13 Commits

Author SHA1 Message Date
55a084e762 fix(blog): revert face detection imgproxy gravity causing 500 errors on standard open source image
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 55s
Build & Deploy / ⚡ Lighthouse (push) Successful in 7m18s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-21 14:09:35 +01:00
2b09cfc5d9 fix(ui): always show background on mobile navbar to prevent contrast issues
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 5m34s
Build & Deploy / 🏗️ Build (push) Successful in 7m54s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
2026-02-21 13:57:06 +01:00
927ce977f2 chore: release v1.2.6 with Next.js LCP, Hydration and Prod-Visibility fixes
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 42s
Build & Deploy / 🧪 QA (push) Successful in 5m17s
Build & Deploy / 🏗️ Build (push) Successful in 8m36s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Smoke Test (push) Successful in 53s
Build & Deploy / ⚡ Lighthouse (push) Successful in 7m38s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 13:26:14 +01:00
85bc03b9d2 fix(ci): pass correct UMAMI_WEBSITE_ID variable to docker build and env file
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 39s
Build & Deploy / 🧪 QA (push) Successful in 7m49s
Build & Deploy / 🏗️ Build (push) Successful in 8m24s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m47s
Build & Deploy / 🧪 Smoke Test (push) Successful in 3m16s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 11:12:06 +01:00
c4bc10ef76 fix: restore missing SVG animations in HeroIllustration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Failing after 17s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-21 01:56:09 +01:00
e95f7c6dd2 fix: restore missing translations on 404 page 2026-02-21 00:38:49 +01:00
17a91e48e6 perf: pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 8m19s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 54s
Build & Deploy / ⚡ Lighthouse (push) Successful in 8m38s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 23:42:33 +01:00
4d0a94d288 perf: pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 9m1s
Build & Deploy / 🏗️ Build (push) Successful in 12m31s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m50s
Build & Deploy / 🧪 Smoke Test (push) Successful in 3m34s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 21:24:12 +01:00
3568c13941 perf: pipeline 2026-02-20 19:06:19 +01:00
d538d7b9ec fix(blog): ensure target environment vars are parsed for accurate strict filtering in prod, and integrate face detection gravity for blog thumbnails
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-20 18:54:09 +01:00
8c08b552cf fix: svg stroke width 2026-02-20 18:48:10 +01:00
1dd74a3861 ci: fix pipeline cache corruption and secrets warning
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-20 16:20:53 +01:00
8d77ca45f7 feat(blog): implement scheduled and draft posts filtering and preview UI
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m59s
Build & Deploy / 🏗️ Build (push) Failing after 34s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 15:41:07 +01:00
28 changed files with 694 additions and 702 deletions

View File

@@ -10,17 +10,31 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 version: 10
run_install: false run_install: false
- 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:
node-version: 20
- name: 🔐 Configure Private Registry - name: 🔐 Configure Private Registry
run: | run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"

View File

@@ -154,14 +154,26 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 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:
node-version: 20
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
@@ -202,12 +214,12 @@ jobs:
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.REGISTRY_PASS }} NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }} 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 cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache-v2,mode=max
secrets: | secrets: |
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}" "NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
@@ -254,7 +266,7 @@ jobs:
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
# Analytics # Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -321,7 +333,7 @@ jobs:
echo "COOKIE_DOMAIN=$COOKIE_DOMAIN" echo "COOKIE_DOMAIN=$COOKIE_DOMAIN"
echo "" echo ""
echo "# Analytics" echo "# Analytics"
echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
@@ -386,14 +398,26 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 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:
node-version: 20
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
@@ -418,14 +442,26 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v3 uses: pnpm/action-setup@v3
with: with:
version: 10 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:
node-version: 20
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc

4
.gitignore vendored
View File

@@ -11,4 +11,6 @@ lighthouserc.cjs
directus/uploads directus/uploads
!directus/extensions/ !directus/extensions/
!directus/schema/ !directus/schema/
!directus/migrations/ !directus/migrations/
.next-docker

View File

@@ -8,7 +8,6 @@ ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
@@ -25,7 +24,7 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
# Configure private registry and install dependencies # Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \ --mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \ export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \ echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \ echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
pnpm install --frozen-lockfile && \ pnpm install --frozen-lockfile && \

View File

@@ -5,6 +5,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog'; import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
import { Metadata } from 'next'; import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation'; import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA'; import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents'; import TableOfContents from '@/components/blog/TableOfContents';
@@ -69,13 +70,24 @@ export default async function BlogPost({ params }: BlogPostProps) {
category={post.frontmatter.category} category={post.frontmatter.category}
readingTime={getReadingTime(post.content)} readingTime={getReadingTime(post.content)}
/> />
{(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && (
<div className="bg-orange-500 text-white text-center py-2 px-4 font-bold text-sm tracking-wider uppercase relative z-50">
Preview (Not visible in production)
</div>
)}
{/* Featured Image Header */} {/* Featured Image Header */}
{post.frontmatter.featuredImage ? ( {post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group"> <div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div <div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100" <Image
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }} src={post.frontmatter.featuredImage}
/> alt={post.frontmatter.title}
fill
priority
className="object-cover"
sizes="100vw"
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
{/* Title overlay on image */} {/* Title overlay on image */}
@@ -84,18 +96,18 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="max-w-4xl"> <div className="max-w-4xl">
{post.frontmatter.category && ( {post.frontmatter.category && (
<div className="overflow-hidden mb-6"> <div className="overflow-hidden mb-6">
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom"> <span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
{post.frontmatter.category} {post.frontmatter.category}
</span> </span>
</div> </div>
)} )}
<Heading <Heading
level={1} level={1}
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]" className="text-white mb-8 drop-shadow-2xl"
> >
{post.frontmatter.title} {post.frontmatter.title}
</Heading> </Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]"> <div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
<time dateTime={post.frontmatter.date}> <time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, { {new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric', year: 'numeric',
@@ -145,7 +157,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="sticky-narrative-content"> <div className="sticky-narrative-content">
{/* Excerpt/Lead paragraph if available */} {/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && ( {post.frontmatter.excerpt && (
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]"> <div className="mb-16">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic"> <p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{post.frontmatter.excerpt} {post.frontmatter.excerpt}
</p> </p>
@@ -153,7 +165,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
)} )}
{/* Main content with enhanced styling */} {/* Main content with enhanced styling */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]"> <div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
<MDXRemote source={post.content} components={mdxComponents} /> <MDXRemote source={post.content} components={mdxComponents} />
</div> </div>

View File

@@ -75,9 +75,16 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<Badge variant="saturated" className="mb-4 md:mb-6"> <div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
{t('featuredPost')} <Badge variant="saturated">{t('featuredPost')}</Badge>
</Badge> {featuredPost &&
(new Date(featuredPost.frontmatter.date) > new Date() ||
featuredPost.frontmatter.public === false) && (
<Badge variant="accent" className="bg-orange-500 text-white border-none">
Preview
</Badge>
)}
</div>
{featuredPost && ( {featuredPost && (
<> <>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
@@ -168,6 +175,15 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{post.frontmatter.category} {post.frontmatter.category}
</Badge> </Badge>
)} )}
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<Badge
variant="accent"
className="absolute top-3 right-3 md:top-6 md:right-6 shadow-lg bg-orange-500 text-white border-none"
>
Preview
</Badge>
)}
</div> </div>
)} )}
<div className="p-5 md:p-10 flex flex-col flex-1"> <div className="p-5 md:p-10 flex flex-col flex-1">

View File

@@ -81,7 +81,16 @@ export default async function Layout(props: {
} }
// Pick only the namespaces required by client components to reduce the hydration payload size // Pick only the namespaces required by client components to reduce the hydration payload size
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home']; const clientKeys = [
'Footer',
'Navigation',
'Contact',
'Products',
'Team',
'Home',
'Error',
'StandardPage',
];
const clientMessages: Record<string, any> = {}; const clientMessages: Record<string, any> = {};
for (const key of clientKeys) { for (const key of clientKeys) {
if (messages[key]) { if (messages[key]) {

39
chunk-analysis.json Normal file
View File

@@ -0,0 +1,39 @@
.next/static/chunks/1555f2949dbcddff.js
.next/static/chunks/180f5dcf81481335.js
.next/static/chunks/268553c5293137f5.js
.next/static/chunks/2d7ae32de68a39ef.js
.next/static/chunks/2ec0936da0321266.js
.next/static/chunks/37f7b54a37295c30.js
.next/static/chunks/3e3942369abf2ddc.js
.next/static/chunks/424d0a83ac1f43b8.js
.next/static/chunks/47f749213f3cceab.js
.next/static/chunks/487d683c339d19a3.js
.next/static/chunks/535c1ab943e23448.js
.next/static/chunks/558d909c3c1972b3.js
.next/static/chunks/6cf611207add5a99.js
.next/static/chunks/7bd9cb4fe778d0a3.js
.next/static/chunks/817ca2bc66023675.js
.next/static/chunks/83318e1ba94652b0.js
.next/static/chunks/882400359e57d35e.js
.next/static/chunks/91829f600ae9b629.js
.next/static/chunks/98b8bfc9eb444163.js
.next/static/chunks/9aed5432afcf0f2d.js
.next/static/chunks/a1d67bb574863461.js
.next/static/chunks/a6dad97d9634a72d.js
.next/static/chunks/a71a8075e35ac509.js
.next/static/chunks/afc79511da623949.js
.next/static/chunks/b5c2a6630c37e020.js
.next/static/chunks/bcded4d8b49a0260.js
.next/static/chunks/c2f4c81be736500f.js
.next/static/chunks/c4008b16a5e99b90.js
.next/static/chunks/c98e4e432699368d.js
.next/static/chunks/c9e37d1e4c73c7f0.js
.next/static/chunks/d79d122fe6c2ca13.js
.next/static/chunks/db3ea84e87f72a70.js
.next/static/chunks/e39a48c430164d16.js
.next/static/chunks/e58096c0ead62031.js
.next/static/chunks/ed779de026be3e39.js
.next/static/chunks/f7b46fbdaad1733e.js
.next/static/chunks/fa833b5e3015d34f.js
.next/static/chunks/fc4f94c4cc594aa4.js
.next/static/chunks/turbopack-5e3bd8a685a47b49.js

View File

@@ -2,7 +2,6 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
@@ -18,7 +17,6 @@ export default function Header() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; const currentLocale = pathname.split('/')[1] || 'en';
@@ -35,7 +33,6 @@ export default function Header() {
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap // Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
@@ -102,9 +99,9 @@ export default function Header() {
]; ];
const headerClass = cn( const headerClass = cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu', 'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu animate-in fade-in slide-in-from-top-12 fill-mode-both',
{ {
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen, 'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none': isHomePage && !isScrolled && !isMobileMenuOpen,
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen, 'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
}, },
); );
@@ -114,263 +111,41 @@ export default function Header() {
return ( return (
<> <>
<LazyMotion strict features={domAnimation}> <header className={headerClass} style={{ animationDuration: '800ms' }}>
<m.header <div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
className={headerClass} <div
initial={{ y: -100, opacity: 0 }} className="flex-shrink-0 group touch-target animate-in fade-in zoom-in-90 fill-mode-both"
animate={{ y: 0, opacity: 1 }} style={{ animationDuration: '600ms', animationDelay: '100ms' }}
transition={{ duration: 0.8, ease: 'easeOut' }} >
> <Link
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between"> href={`/${currentLocale}`}
<m.div onClick={() =>
className="flex-shrink-0 group touch-target" trackEvent(AnalyticsEvents.BUTTON_CLICK, {
initial={{ scale: 0.8, opacity: 0 }} target: 'home_logo',
animate={{ scale: 1, opacity: 1 }} location: 'header',
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }} })
}
> >
<Link <Image
href={`/${currentLocale}`} src={logoSrc}
onClick={() => alt={t('home')}
trackEvent(AnalyticsEvents.BUTTON_CLICK, { width={120}
target: 'home_logo', height={120}
location: 'header', className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
}) priority
} />
> </Link>
<Image
src={logoSrc}
alt={t('home')}
width={120}
height={120}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
/>
</Link>
</m.div>
<m.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<m.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</m.div>
))}
</m.nav>
<m.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<m.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'en',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</m.div>
<m.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'de',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Mobile Menu Button */}
<m.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<m.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{isMobileMenuOpen ? (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</m.svg>
</m.button>
</m.div>
</div> </div>
{/* Mobile Menu Overlay */}
<div <div
className={cn( className="flex items-center gap-4 md:gap-12"
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
> >
<m.nav <nav className="hidden lg:flex items-center space-x-10">
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
{menuItems.map((item, idx) => ( {menuItems.map((item, idx) => (
<m.div <div
key={item.href} key={item.href}
variants={{ className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
closed: { opacity: 0, y: 50, scale: 0.9 }, style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
open: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
},
}}
> >
<Link <Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
@@ -379,128 +154,214 @@ export default function Header() {
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label, label: item.label,
href: item.href, href: item.href,
location: 'mobile_menu', location: 'header_nav',
}); });
}} }}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4" className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
> >
{item.label} {item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link> </Link>
</m.div> </div>
))} ))}
</nav>
<m.div <div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8" className={cn('hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both', textColorClass)}
initial={{ opacity: 0, y: 30 }} style={{ animationDuration: '600ms', animationDelay: '300ms' }}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }} >
transition={{ duration: 0.5, delay: 0.8 }} <div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
> >
<m.div <div>
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white" <Link
initial={{ opacity: 0, scale: 0.8 }} href={getPathForLocale('en')}
animate={{ opacity: 1, scale: 1 }} onClick={() =>
transition={{ duration: 0.4, delay: 0.9 }} trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
> type: 'language',
<m.div from: currentLocale,
initial={{ opacity: 0 }} to: 'en',
animate={{ opacity: 1 }} location: 'header',
transition={{ duration: 0.3, delay: 1.0 }} })
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
> >
<Link EN
href={getPathForLocale('en')} </Link>
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`} </div>
> <div className="w-px h-4 bg-current opacity-20" />
EN <div>
</Link> <Link
</m.div> href={getPathForLocale('de')}
<m.div onClick={() =>
className="w-px h-6 bg-white/20" trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
initial={{ scaleX: 0 }} type: 'language',
animate={{ scaleX: 1 }} from: currentLocale,
transition={{ duration: 0.4, delay: 1.05 }} to: 'de',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</div>
</div>
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
>
{t('contact')}
</Button>
</div>
</div>
{/* Mobile Menu Button */}
<button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50 transition-all duration-300',
textColorClass,
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100'
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<svg
className="w-7 h-7 transition-transform duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isMobileMenuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/> />
<m.div ) : (
initial={{ opacity: 0 }} <path
animate={{ opacity: 1 }} strokeLinecap="round"
transition={{ duration: 0.3, delay: 1.1 }} strokeLinejoin="round"
> strokeWidth={2}
<Link d="M4 6h16M4 12h16M4 18h16"
href={getPathForLocale('de')} />
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`} )}
> </svg>
DE </button>
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Bottom Branding */}
<m.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<m.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</m.div>
</m.div>
</m.nav>
</div> </div>
</m.header> </div>
</LazyMotion>
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
>
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
{menuItems.map((item, idx) => (
<div
key={item.href}
className={cn('transition-all duration-500 transform', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')}
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
>
{item.label}
</Link>
</div>
))}
<div
className={cn('pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500', isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8')}
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
>
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
<div>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</div>
<div className="w-px h-6 bg-white/20" />
<div>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</div>
</div>
<div className="w-full max-w-xs">
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
>
{t('contact')}
</Button>
</div>
</div>
{/* Bottom Branding */}
<div
className={cn('p-12 flex justify-center transition-all duration-700', isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75')}
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</div>
</nav>
</div>
</header>
</> </>
); );
} }
const navVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.06,
delayChildren: 0.1,
},
},
} as const;
const navLinkVariants = {
hidden: { opacity: 0, y: 20, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.5,
ease: 'easeOut',
},
},
} as const;
const headerRightVariants = {
hidden: { opacity: 0, x: 30 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
} as const;

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { m, LazyMotion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {
@@ -139,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<LazyMotion strict features={domAnimation}> <LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<div <div

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
import { cn } from '@/components/ui'; import { cn } from '@/components/ui';
interface ScribbleProps { interface ScribbleProps {
@@ -11,67 +10,49 @@ interface ScribbleProps {
} }
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) { export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
const pathVariants: Variants = {
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
duration: 1.8,
ease: 'easeInOut',
},
},
};
if (variant === 'circle') { if (variant === 'circle') {
return ( return (
<LazyMotion strict features={domAnimation}> <svg
<svg className={cn('absolute pointer-events-none', className)}
className={cn('absolute pointer-events-none', className)} aria-hidden="true"
aria-hidden="true" viewBox="0 0 800 350"
viewBox="0 0 800 350" preserveAspectRatio="none"
preserveAspectRatio="none" >
> <path
<m.path className="animate-draw-stroke"
variants={pathVariants} pathLength="1"
initial="hidden" style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
whileInView="visible" transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
viewport={{ once: true }} strokeLinejoin="miter"
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)" fillOpacity="0"
strokeLinejoin="miter" strokeMiterlimit="4"
fillOpacity="0" stroke={color}
strokeMiterlimit="4" strokeOpacity="1"
stroke={color} strokeWidth="20"
strokeOpacity="1" d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
strokeWidth="20" />
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734" </svg>
/>
</svg>
</LazyMotion>
); );
} }
if (variant === 'underline') { if (variant === 'underline') {
return ( return (
<LazyMotion strict features={domAnimation}> <svg
<svg className={cn('absolute pointer-events-none', className)}
className={cn('absolute pointer-events-none', className)} aria-hidden="true"
aria-hidden="true" viewBox="-400 -55 730 60"
viewBox="-400 -55 730 60" preserveAspectRatio="none"
preserveAspectRatio="none" >
> <path
<m.path className="animate-draw-stroke"
variants={pathVariants} pathLength="1"
initial="hidden" style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
whileInView="visible" d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
viewport={{ once: true }} stroke={color}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15" strokeWidth="20"
stroke={color} fill="none"
strokeWidth="20" />
fill="none" </svg>
/>
</svg>
</LazyMotion>
); );
} }

View File

@@ -3,7 +3,8 @@ import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
import Lightbox from '../Lightbox'; import dynamic from 'next/dynamic';
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
export default function GallerySection() { export default function GallerySection() {

View File

@@ -2,7 +2,6 @@
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
@@ -16,185 +15,79 @@ export default function Hero() {
return ( return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0"> <Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<LazyMotion strict features={domAnimation}> <Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none"> <div className="max-w-5xl mx-auto md:mx-0">
<m.div <div className="animate-in fade-in slide-in-from-bottom-8 duration-700 ease-out fill-mode-both" style={{ animationDelay: '100ms' }}>
className="max-w-5xl mx-auto md:mx-0" <Heading
initial="hidden" level={1}
animate="visible" className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
variants={containerVariants}
>
<m.div variants={headingVariants}>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<m.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</m.span>
<m.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</m.div>
</span>
),
})}
</Heading>
</m.div>
<m.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</m.div>
<m.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
> >
<m.div variants={buttonVariants}> {t.rich('title', {
<Button green: (chunks) => (
href="/contact" <span className="relative inline-block">
variant="accent" <span className="relative z-10 text-accent italic animate-in fade-in zoom-in-95 duration-700 ease-out fill-mode-both inline-block" style={{ animationDelay: '300ms' }}>
size="lg" {chunks}
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg" </span>
onClick={() => <div className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '500ms' }}>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { <Scribble variant="circle" />
label: t('cta'), </div>
location: 'home_hero_primary', </span>
}) ),
} })}
> </Heading>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</m.div>
<m.div variants={buttonVariants}>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{t('exploreProducts')}
</Button>
</m.div>
</m.div>
</m.div>
</Container>
<m.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration />
</m.div>
<m.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<m.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div> </div>
</m.div> <div className="animate-in fade-in slide-in-from-bottom-4 duration-700 ease-out fill-mode-both" style={{ animationDelay: '400ms' }}>
</LazyMotion> <p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6 animate-in fade-in slide-in-from-bottom-6 duration-700 ease-out fill-mode-both" style={{ animationDelay: '600ms' }}>
<div>
<Button
href="/contact"
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('cta'),
location: 'home_hero_primary',
})
}
>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</div>
<div>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{t('exploreProducts')}
</Button>
</div>
</div>
</div>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '100ms' }}>
<HeroIllustration />
</div>
<div className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both" style={{ animationDelay: '2000ms' }}>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div>
</div>
</Section> </Section>
); );
} }
const containerVariants = {
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
} as const;
const headingVariants = {
hidden: { opacity: 1, y: 10, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;
const accentVariants = {
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;
const scribbleVariants = {
hidden: { opacity: 0, scale: 0, rotate: 180 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
},
} as const;
const subtitleVariants = {
hidden: { opacity: 1, y: 20, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
},
} as const;
const buttonContainerVariants = {
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.4,
},
},
} as const;
const buttonVariants = {
hidden: { opacity: 1, y: 30, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: 'spring', stiffness: 400, damping: 20 },
},
} as const;

View File

@@ -125,8 +125,9 @@ export default function HeroIllustration() {
}, []); }, []);
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100'; const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
const scale = isMobile ? 1.44 : 1; // Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance
const opacity = isMobile ? 0.6 : 0.85; const scale = isMobile ? 1.6 : 1;
const opacity = isMobile ? 0.9 : 0.85;
return ( return (
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full"> <div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
@@ -154,15 +155,15 @@ export default function HeroIllustration() {
<stop offset="70%" stopColor="white" stopOpacity="0.4" /> <stop offset="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" /> <stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient> </linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%"> <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur" /> <feGaussianBlur stdDeviation="3" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
</feMerge> </feMerge>
</filter> </filter>
<filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%"> <filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="1" result="blur" /> <feGaussianBlur stdDeviation="2" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
@@ -215,10 +216,10 @@ export default function HeroIllustration() {
</g> </g>
{/* ANIMATED ENERGY FLOW */} {/* ANIMATED ENERGY FLOW */}
<g> <g filter="url(#glow)">
{POWER_LINES.map((line, i) => { {POWER_LINES.map((line, i) => {
// Only animate a small subset of lines to reduce main-thread work significantly // Only animate a subset of lines to reduce main-thread work
if (i % 5 !== 0) return null; if (i % 2 !== 0) return null;
const from = gridToScreen(line.from.col, line.from.row); const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row); const to = gridToScreen(line.to.col, line.to.row);
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)); const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
@@ -232,12 +233,16 @@ export default function HeroIllustration() {
stroke="url(#energy-pulse)" stroke="url(#energy-pulse)"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
style={{ strokeDasharray={`${length * 0.2} ${length * 0.8}`}
strokeDasharray: `${length * 0.2} ${length * 0.8}`, >
strokeDashoffset: length, <animate
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`, attributeName="stroke-dashoffset"
}} from={length}
/> to={0}
dur={`${1.5 + (i % 3) * 0.5}s`}
repeatCount="indefinite"
/>
</line>
); );
})} })}
</g> </g>
@@ -263,13 +268,14 @@ export default function HeroIllustration() {
strokeWidth="1" strokeWidth="1"
strokeOpacity="0.3" strokeOpacity="0.3"
/> />
<circle <circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
r="3" <animate
fill="#82ed20" attributeName="fillOpacity"
fillOpacity="0.3" values="0.2;0.5;0.2"
filter="url(#soft-glow)" dur="2s"
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }} repeatCount="indefinite"
/> />
</circle>
</g> </g>
); );
})} })}
@@ -289,26 +295,28 @@ export default function HeroIllustration() {
strokeOpacity="0.3" strokeOpacity="0.3"
/> />
<g transform="translate(0, -60)"> <g transform="translate(0, -60)">
<g {[0, 120, 240].map((angle, j) => (
style={{ <line
transformOrigin: '0px 0px', key={`blade-${i}-${j}`}
animation: `spin-slow ${3 + i}s linear infinite`, x1="0"
}} y1="0"
> x2="0"
{[0, 120, 240].map((angle, j) => ( y2="-30"
<line stroke="white"
key={`blade-${i}-${j}`} strokeWidth="1.5"
x1="0" strokeOpacity="0.4"
y1="0" transform={`rotate(${angle})`}
x2="0" >
y2="-30" <animateTransform
stroke="white" attributeName="transform"
strokeWidth="1.5" type="rotate"
strokeOpacity="0.4" from={`${angle} 0 0`}
transform={`rotate(${angle})`} to={`${angle + 360} 0 0`}
dur={`${3 + i}s`}
repeatCount="indefinite"
/> />
))} </line>
</g> ))}
</g> </g>
</g> </g>
); );

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion'; import { m, LazyMotion, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() { export function PlaybackCursor() {
@@ -24,7 +24,7 @@ export function PlaybackCursor() {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<LazyMotion strict features={domAnimation}> <LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<m.div <m.div
className="fixed z-[10000] pointer-events-none" className="fixed z-[10000] pointer-events-none"
animate={{ animate={{

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion'; import { Reorder, AnimatePresence, LazyMotion } from 'framer-motion';
import { import {
Play, Play,
Square, Square,
@@ -146,7 +146,7 @@ export function RecordModeOverlay() {
} }
return ( return (
<LazyMotion strict features={domAnimation}> <LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans"> <div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
{/* 1. Global Toolbar - Slim Industrial Bar */} {/* 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="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">

View File

@@ -10,7 +10,6 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null); const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
setMounted(true);
// Explicit non-magical detection // Explicit non-magical detection
const embedded = const embedded =
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe'; window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
@@ -21,13 +20,12 @@ export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
url.searchParams.set('embedded', 'true'); url.searchParams.set('embedded', 'true');
setIframeUrl(url.toString()); setIframeUrl(url.toString());
} }
}, [isEmbedded]); }, []);
// Hydration Guard: Match server on first render
if (!mounted) return <>{children}</>;
// Recursion Guard: If we are already in an embedded iframe, // Recursion Guard: If we are already in an embedded iframe,
// strictly return just the children to prevent Inception. // 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) { if (isEmbedded) {
return ( return (
<> <>

View File

@@ -3,7 +3,8 @@
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Lightbox from '@/components/Lightbox'; import dynamic from 'next/dynamic';
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
import { Section, Container, Heading } from '@/components/ui'; import { Section, Container, Heading } from '@/components/ui';
export default function Gallery() { export default function Gallery() {

View File

@@ -1,10 +1,15 @@
{ {
"ci": { "ci": {
"collect": { "collect": {
"numberOfRuns": 1, "numberOfRuns": 3,
"settings": { "settings": {
"preset": "desktop", "preset": "desktop",
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"], "onlyCategories": [
"performance",
"accessibility",
"best-practices",
"seo"
],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox" "chromeFlags": "--no-sandbox --disable-setuid-sandbox"
} }
}, },
@@ -49,4 +54,4 @@
} }
} }
} }
} }

View File

@@ -5,6 +5,7 @@ featuredImage: /uploads/2026/01/1767353529807.jpg
locale: de locale: de
category: Kabel Technologie category: Kabel Technologie
excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.' excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.'
public: false
--- ---
# Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch # Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch

View File

@@ -5,6 +5,7 @@ featuredImage: /uploads/2026/01/1767353529807.jpg
locale: en locale: en
category: Cable Technology category: Cable Technology
excerpt: 'KLZ Cables kicks off the new year with a strong addition: Johannes Gleich takes on the role of Senior Key Account Manager. Learn more about our new expert for infrastructure and energy suppliers.' excerpt: 'KLZ Cables kicks off the new year with a strong addition: Johannes Gleich takes on the role of Senior Key Account Manager. Learn more about our new expert for infrastructure and energy suppliers.'
public: false
--- ---
# Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager # Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager

View File

@@ -183,9 +183,9 @@ services:
# This fixes the Next.js URL-decoding bug on dynamic image proxy paths # This fixes the Next.js URL-decoding bug on dynamic image proxy paths
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=websecure" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=true" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-le}" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080" - "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip" - "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip"

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs'; import { mapSlugToFileSlug } from './slugs';
import { config } from '@/lib/config';
export interface PostFrontmatter { export interface PostFrontmatter {
title: string; title: string;
@@ -10,6 +11,7 @@ export interface PostFrontmatter {
featuredImage?: string | null; featuredImage?: string | null;
category?: string; category?: string;
locale: string; locale: string;
public?: boolean;
} }
export interface PostMdx { export interface PostMdx {
@@ -18,6 +20,17 @@ export interface PostMdx {
content: string; content: string;
} }
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
// If explicitly marked as not public, hide in production
if (post.frontmatter.public === false && config.isProduction) {
return false;
}
const postDate = new Date(post.frontmatter.date);
const now = new Date();
return !(postDate > now && config.isProduction);
}
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> { export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
// Map translated slug to file slug // Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale); const fileSlug = await mapSlugToFileSlug(slug, locale);
@@ -31,11 +44,17 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
const fileContent = fs.readFileSync(filePath, 'utf8'); const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent); const { data, content } = matter(fileContent);
return { const postInfo = {
slug: fileSlug, slug: fileSlug,
frontmatter: data as PostFrontmatter, frontmatter: data as PostFrontmatter,
content, content,
}; };
if (!isPostVisible(postInfo)) {
return null;
}
return postInfo;
} }
export async function getAllPosts(locale: string): Promise<PostMdx[]> { export async function getAllPosts(locale: string): Promise<PostMdx[]> {
@@ -55,6 +74,7 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
content, content,
}; };
}) })
.filter(isPostVisible)
.sort( .sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(), (a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
); );
@@ -78,6 +98,7 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
frontmatter: data as PostFrontmatter, frontmatter: data as PostFrontmatter,
}; };
}) })
.filter(isPostVisible)
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.frontmatter.date as string).getTime() - new Date(b.frontmatter.date as string).getTime() -

View File

@@ -20,6 +20,8 @@ const booleanSchema = z.preprocess((val) => {
const envExtension = { const envExtension = {
// Project specific overrides or additions // Project specific overrides or additions
AUTH_COOKIE_NAME: z.string().default('klz_gatekeeper_session'), AUTH_COOKIE_NAME: z.string().default('klz_gatekeeper_session'),
TARGET: z.string().optional(),
NEXT_PUBLIC_TARGET: z.string().optional(),
// Gatekeeper specifics not in base // Gatekeeper specifics not in base
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'), GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),

2
lib/framer-features.ts Normal file
View File

@@ -0,0 +1,2 @@
import { domAnimation } from 'framer-motion';
export default domAnimation;

View File

@@ -28,6 +28,6 @@ export default function imgproxyLoader({
return getImgproxyUrl(src, { return getImgproxyUrl(src, {
width, width,
resizing_type: 'fit', resizing_type: 'fit',
gravity: 'sm', // Use smart gravity (content-aware) instead of face detection (requires ML) gravity: 'sm', // Use smart gravity (content-aware) instead of face detection (requires ML/Pro)
}); });
} }

View File

@@ -58,9 +58,10 @@ async function main() {
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`, `⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
); );
// Try to pick a variety: home, some products, some blog posts // Try to pick a variety: home, some products, some blog posts
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl); const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
const others = urls.filter((u) => !home.includes(u)); const homeDE = urls.filter((u) => u.endsWith('/de'));
urls = [...home, ...others.slice(0, limit - home.length)]; const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
} }
console.log(`🧪 Pages to be tested:`); console.log(`🧪 Pages to be tested:`);

89
tmp-lcp.json Normal file
View File

@@ -0,0 +1,89 @@
{
"id": "largest-contentful-paint-element",
"title": "Largest Contentful Paint element",
"description": "This is the largest contentful element painted within the viewport. [Learn more about the Largest Contentful Paint element](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-largest-contentful-paint/)",
"score": 0,
"scoreDisplayMode": "metricSavings",
"displayValue": "5,490 ms",
"metricSavings": {
"LCP": 3000
},
"details": {
"type": "list",
"items": [
{
"type": "table",
"headings": [
{
"key": "node",
"valueType": "node",
"label": "Element"
}
],
"items": [
{
"node": {
"type": "node",
"lhId": "page-0-IMG",
"path": "1,HTML,1,BODY,27,DIV,0,DIV,0,DIV,0,DIV,0,DIV,2,HEADER,0,DIV,0,DIV,0,A,0,IMG",
"selector": "div.container > div.flex-shrink-0 > a > img.h-10",
"boundingRect": {
"top": 20,
"bottom": 60,
"left": 32,
"right": 151,
"width": 119,
"height": 40
},
"snippet": "<img alt=\"Startseite\" width=\"120\" height=\"120\" decoding=\"async\" data-nimg=\"1\" class=\"h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110\" srcset=\"/logo-white.svg 1x, /logo-white.svg 2x\" src=\"/logo-white.svg\" style=\"color: transparent;\">",
"nodeLabel": "Startseite"
}
}
]
},
{
"type": "table",
"headings": [
{
"key": "phase",
"valueType": "text",
"label": "Phase"
},
{
"key": "percent",
"valueType": "text",
"label": "% of LCP"
},
{
"key": "timing",
"valueType": "ms",
"label": "Timing"
}
],
"items": [
{
"phase": "TTFB",
"timing": 459.198,
"percent": "8%"
},
{
"phase": "Load Delay",
"timing": 58.11240179828974,
"percent": "1%"
},
{
"phase": "Load Time",
"timing": 49.75227841406388,
"percent": "1%"
},
{
"phase": "Render Delay",
"timing": 4920.729319787644,
"percent": "90%"
}
]
}
]
},
"guidanceLevel": 1
}