Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 678c803408 | |||
| 21288a4a45 | |||
| b514125e0d | |||
| 55a084e762 |
@@ -77,7 +77,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl">
|
||||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||||
{t('badge')}
|
{t('badge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -93,7 +93,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Excerpt/Lead paragraph if available */}
|
{/* Excerpt/Lead paragraph if available */}
|
||||||
{pageData.frontmatter.excerpt && (
|
{pageData.frontmatter.excerpt && (
|
||||||
<div className="mb-16 animate-slight-fade-in-from-bottom">
|
<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">
|
||||||
{pageData.frontmatter.excerpt}
|
{pageData.frontmatter.excerpt}
|
||||||
</p>
|
</p>
|
||||||
@@ -101,7 +101,7 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content with shared blog components */}
|
{/* Main content with shared blog components */}
|
||||||
<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">
|
<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={pageData.content} components={mdxComponents} />
|
<MDXRemote source={pageData.content} components={mdxComponents} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -70,11 +70,7 @@ 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">
|
||||||
@@ -101,10 +97,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Heading
|
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
|
||||||
level={1}
|
|
||||||
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">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
@@ -117,6 +110,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(post.content)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">Draft Preview</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,6 +144,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</time>
|
</time>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
<span>{getReadingTime(post.content)} min read</span>
|
<span>{getReadingTime(post.content)} min read</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">Draft Preview</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -176,7 +181,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
{/* Post Navigation */}
|
{/* Post Navigation */}
|
||||||
<div className="mt-16">
|
<div className="mt-16">
|
||||||
<PostNavigation prev={prev} next={next} locale={locale} />
|
<PostNavigation prev={prev} next={next} isPrevRandom={isPrevRandom} isNextRandom={isNextRandom} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back to blog link */}
|
{/* Back to blog link */}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
src={`${featuredPost.frontmatter.featuredImage}?gravity=obj:face`}
|
src={`${featuredPost.frontmatter.featuredImage}?gravity=obj:face`}
|
||||||
alt={featuredPost.frontmatter.title}
|
alt={featuredPost.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
|
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
@@ -74,14 +74,14 @@ 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">
|
||||||
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
|
||||||
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
<Badge variant="saturated">{t('featuredPost')}</Badge>
|
||||||
{featuredPost &&
|
{featuredPost &&
|
||||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
featuredPost.frontmatter.public === false) && (
|
featuredPost.frontmatter.public === false) && (
|
||||||
<Badge variant="accent" className="bg-orange-500 text-white border-none">
|
<Badge variant="neutral" className="border border-white/30 bg-transparent text-white/80 shadow-none">
|
||||||
Preview
|
Draft Preview
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,24 +175,22 @@ 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">
|
||||||
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
<div className="flex items-center gap-3 text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
<span>
|
||||||
year: 'numeric',
|
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||||
month: 'long',
|
year: 'numeric',
|
||||||
day: 'numeric',
|
month: 'long',
|
||||||
})}
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">Draft</span>
|
||||||
|
)}
|
||||||
</div>
|
</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-2 leading-tight">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
|
|||||||
@@ -30,14 +30,15 @@ export async function generateMetadata(props: {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { locale } = params;
|
const { locale } = params;
|
||||||
|
|
||||||
|
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(baseUrl),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: locale === 'en' ? '/' : `/${locale}`,
|
canonical: `${baseUrl}/${locale}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: '/de',
|
de: `${baseUrl}/de`,
|
||||||
en: '/en',
|
en: `${baseUrl}/en`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
@@ -76,7 +77,6 @@ export default async function Layout(props: {
|
|||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
|
||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,10 @@ export default async function Layout(props: {
|
|||||||
const { headers } = await import('next/headers');
|
const { headers } = await import('next/headers');
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
if ('setServerContext' in serverServices.analytics) {
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
// Skip setting server context for analytics in CI
|
||||||
|
} else if ('setServerContext' in serverServices.analytics) {
|
||||||
(serverServices.analytics as any).setServerContext({
|
(serverServices.analytics as any).setServerContext({
|
||||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||||
|
|||||||
@@ -52,18 +52,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Sentry/GlitchTip API responded with error', {
|
if (!process.env.CI) {
|
||||||
status: response.status,
|
logger.error('Sentry/GlitchTip API responded with error', {
|
||||||
error: errorText.slice(0, 100),
|
status: response.status,
|
||||||
});
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
return new NextResponse(errorText, { status: response.status });
|
return new NextResponse(errorText, { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: 'ok' });
|
return NextResponse.json({ status: 'ok' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to relay Sentry request', {
|
if (!process.env.CI) {
|
||||||
error: (error as Error).message,
|
logger.error('Failed to relay Sentry request', {
|
||||||
});
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { getAllPagesMetadata } from '@/lib/pages';
|
|||||||
export const revalidate = 3600; // Revalidate every hour
|
export const revalidate = 3600; // Revalidate every hour
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
const baseUrl = process.env.CI
|
||||||
|
? 'http://klz.localhost'
|
||||||
|
: config.baseUrl || 'https://klz-cables.com';
|
||||||
const locales = ['de', 'en'];
|
const locales = ['de', 'en'];
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
|||||||
@@ -56,10 +56,12 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.error('Umami API responded with error', {
|
if (!process.env.CI) {
|
||||||
status: response.status,
|
logger.error('Umami API responded with error', {
|
||||||
error: errorText.slice(0, 100),
|
status: response.status,
|
||||||
});
|
error: errorText.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
return new NextResponse(errorText, { status: response.status });
|
return new NextResponse(errorText, { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +71,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
// Console error to ensure it appears in logs even if logger fails
|
// Console error to ensure it appears in logs even if logger fails
|
||||||
console.error('CRITICAL PROXY ERROR:', {
|
if (!process.env.CI) {
|
||||||
message: errorMessage,
|
console.error('CRITICAL PROXY ERROR:', {
|
||||||
stack: errorStack,
|
message: errorMessage,
|
||||||
endpoint: config.analytics.umami.apiEndpoint,
|
stack: errorStack,
|
||||||
});
|
endpoint: config.analytics.umami.apiEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
logger.error('Failed to proxy analytics request', {
|
logger.error('Failed to proxy analytics request', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
stack: errorStack,
|
stack: errorStack,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export default function AnalyticsShell() {
|
|||||||
const [shouldLoad, setShouldLoad] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Disable analytics in CI to prevent console noise/score penalties
|
||||||
|
if (process.env.NEXT_PUBLIC_CI === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
|
||||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||||
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { PostMdx } from '@/lib/blog';
|
|||||||
interface PostNavigationProps {
|
interface PostNavigationProps {
|
||||||
prev: PostMdx | null;
|
prev: PostMdx | null;
|
||||||
next: PostMdx | null;
|
next: PostMdx | null;
|
||||||
|
isPrevRandom?: boolean;
|
||||||
|
isNextRandom?: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
|
export default function PostNavigation({ prev, next, isPrevRandom, isNextRandom, locale }: PostNavigationProps) {
|
||||||
if (!prev && !next) return null;
|
if (!prev && !next) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +37,9 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
|
{isPrevRandom
|
||||||
|
? (locale === 'de' ? 'Weiterer Artikel' : 'More Article')
|
||||||
|
: (locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post')}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{prev.frontmatter.title}
|
{prev.frontmatter.title}
|
||||||
@@ -75,7 +79,9 @@ export default function PostNavigation({ prev, next, locale }: PostNavigationPro
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
|
||||||
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
|
||||||
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
|
{isNextRandom
|
||||||
|
? (locale === 'de' ? 'Weiterer Artikel' : 'More Article')
|
||||||
|
: (locale === 'de' ? 'Nächster Beitrag' : 'Next Post')}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
|
||||||
{next.frontmatter.title}
|
{next.frontmatter.title}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
|
<Link
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block my-12 no-underline group"
|
||||||
|
>
|
||||||
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
|
||||||
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
|
||||||
{image ? (
|
{image ? (
|
||||||
@@ -32,8 +37,18 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
<div className="w-full h-full flex items-center justify-center bg-primary/5">
|
||||||
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
className="w-12 h-12 text-primary/20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -46,10 +61,10 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/80 bg-primary/10 px-2 py-0.5 rounded">
|
||||||
External Link
|
External Link
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
|
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/80">
|
||||||
{hostname}
|
{hostname}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,8 +79,18 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
|
||||||
<span>Read more</span>
|
<span>Read more</span>
|
||||||
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
className="w-4 h-4 transition-transform group-hover:translate-x-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function Hero() {
|
|||||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
<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">
|
<div className="max-w-5xl mx-auto md:mx-0">
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-8 duration-700 ease-out fill-mode-both" style={{ animationDelay: '100ms' }}>
|
<div>
|
||||||
<Heading
|
<Heading
|
||||||
level={1}
|
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]"
|
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]"
|
||||||
@@ -25,10 +25,11 @@ export default function Hero() {
|
|||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => (
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
<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' }}>
|
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
|
||||||
{chunks}
|
<div
|
||||||
</span>
|
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"
|
||||||
<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' }}>
|
style={{ animationDelay: '500ms' }}
|
||||||
|
>
|
||||||
<Scribble variant="circle" />
|
<Scribble variant="circle" />
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
@@ -36,12 +37,12 @@ export default function Hero() {
|
|||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 ease-out fill-mode-both" style={{ animationDelay: '400ms' }}>
|
<div>
|
||||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
href="/contact"
|
href="/contact"
|
||||||
@@ -79,11 +80,14 @@ export default function Hero() {
|
|||||||
</div>
|
</div>
|
||||||
</Container>
|
</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' }}>
|
<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">
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</div>
|
</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="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-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 className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
29
lib/blog.ts
29
lib/blog.ts
@@ -109,7 +109,7 @@ export async function getAllPostsMetadata(locale: string): Promise<Partial<PostM
|
|||||||
export async function getAdjacentPosts(
|
export async function getAdjacentPosts(
|
||||||
slug: string,
|
slug: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
): Promise<{ prev: PostMdx | null; next: PostMdx | null; isPrevRandom?: boolean; isNextRandom?: boolean }> {
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||||
|
|
||||||
@@ -120,10 +120,31 @@ export async function getAdjacentPosts(
|
|||||||
// Posts are sorted by date descending (newest first)
|
// Posts are sorted by date descending (newest first)
|
||||||
// So "next" post (newer) is at index - 1
|
// So "next" post (newer) is at index - 1
|
||||||
// And "previous" post (older) is at index + 1
|
// And "previous" post (older) is at index + 1
|
||||||
const next = currentIndex > 0 ? posts[currentIndex - 1] : null;
|
let next = currentIndex > 0 ? posts[currentIndex - 1] : null;
|
||||||
const prev = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
|
let prev = currentIndex < posts.length - 1 ? posts[currentIndex + 1] : null;
|
||||||
|
|
||||||
return { prev, next };
|
let isNextRandom = false;
|
||||||
|
let isPrevRandom = false;
|
||||||
|
|
||||||
|
const getRandomPost = (excludeSlugs: string[]) => {
|
||||||
|
const available = posts.filter(p => !excludeSlugs.includes(p.slug));
|
||||||
|
if (available.length === 0) return null;
|
||||||
|
return available[Math.floor(Math.random() * available.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// If there's no next post (we are at the newest post), show a random post instead
|
||||||
|
if (!next && posts.length > 2) {
|
||||||
|
next = getRandomPost([slug, prev?.slug].filter(Boolean) as string[]);
|
||||||
|
isNextRandom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no previous post (we are at the oldest post), show a random post instead
|
||||||
|
if (!prev && posts.length > 2) {
|
||||||
|
prev = getRandomPost([slug, next?.slug].filter(Boolean) as string[]);
|
||||||
|
isPrevRandom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prev, next, isPrevRandom, isNextRandom };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReadingTime(content: string): number {
|
export function getReadingTime(content: string): number {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
|
|
||||||
export const SITE_URL = (config.baseUrl as string) || 'https://klz-cables.com';
|
const getSiteUrl = () => {
|
||||||
|
if (process.env.CI) return 'http://klz.localhost';
|
||||||
|
return (config.baseUrl as string) || 'https://klz-cables.com';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SITE_URL = getSiteUrl();
|
||||||
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
||||||
|
|
||||||
export const getOrganizationSchema = () => ({
|
export const getOrganizationSchema = () => ({
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
||||||
language: isClient ? navigator.language : this.serverContext?.language,
|
language: isClient ? navigator.language : this.serverContext?.language,
|
||||||
referrer: isClient ? document.referrer : this.serverContext?.referrer,
|
referrer: isClient ? document.referrer : this.serverContext?.referrer,
|
||||||
|
title: isClient ? document.title : undefined,
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ export default function middleware(request: NextRequest) {
|
|||||||
body: request.body,
|
body: request.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||||
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
console.log(
|
||||||
);
|
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ echo "🚀 Starting High-Fidelity Local Audit..."
|
|||||||
|
|
||||||
# 1. Environment and Infrastructure
|
# 1. Environment and Infrastructure
|
||||||
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
|
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
|
||||||
export IMGPROXY_URL="http://img.klz.localhost"
|
export IMGPROXY_URL="http://klz-imgproxy:8080"
|
||||||
export NEXT_URL="http://klz.localhost"
|
export NEXT_URL="http://klz.localhost"
|
||||||
|
export NEXT_PUBLIC_CI=true
|
||||||
|
export CI=true
|
||||||
|
|
||||||
docker network create infra 2>/dev/null || true
|
docker network create infra 2>/dev/null || true
|
||||||
docker volume create klz-cablescom_directus-db-data 2>/dev/null || true
|
docker volume create klz-cablescom_directus-db-data 2>/dev/null || true
|
||||||
@@ -24,6 +26,7 @@ docker-compose up -d --remove-orphans klz-db klz-cms klz-gatekeeper
|
|||||||
echo "🏗️ Building and starting klz-app (Production)..."
|
echo "🏗️ Building and starting klz-app (Production)..."
|
||||||
# We bypass the dev override by explicitly using the base compose file
|
# We bypass the dev override by explicitly using the base compose file
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
|
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
|
||||||
|
NEXT_PUBLIC_CI=true \
|
||||||
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
|
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
|
||||||
|
|
||||||
# 4. Wait for application to be ready
|
# 4. Wait for application to be ready
|
||||||
@@ -47,5 +50,8 @@ echo "✅ App is healthy at $NEXT_URL"
|
|||||||
echo "⚡ Executing Lighthouse CI..."
|
echo "⚡ Executing Lighthouse CI..."
|
||||||
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
|
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
|
||||||
|
|
||||||
|
echo "♿ Executing WCAG Audit..."
|
||||||
|
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=10 pnpm run check:wcag "$NEXT_URL"
|
||||||
|
|
||||||
echo "✨ Audit completed! Summary above."
|
echo "✨ Audit completed! Summary above."
|
||||||
echo "💡 You can stop the production app with: docker-compose stop klz-app"
|
echo "💡 You can stop the production app with: docker-compose stop klz-app"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
--color-accent: #82ed20;
|
--color-accent: #82ed20;
|
||||||
/* Sustainability Green */
|
/* Sustainability Green */
|
||||||
--color-accent-dark: #6bc41a;
|
--color-accent-dark: #14532d;
|
||||||
--color-accent-light: #f0f9e6;
|
--color-accent-light: #f0f9e6;
|
||||||
|
|
||||||
--color-neutral: #f8f9fa;
|
--color-neutral: #f8f9fa;
|
||||||
@@ -153,6 +153,7 @@
|
|||||||
100% {
|
100% {
|
||||||
fill-opacity: 0.2;
|
fill-opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
fill-opacity: 0.5;
|
fill-opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user