Add i18n to home sections, improve consent management and middleware asset handling

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-12 15:57:28 +00:00
parent 6a4055500b
commit 683735cc63
11 changed files with 173 additions and 85 deletions

View File

@@ -3,7 +3,7 @@
import { motion, Variants } from "framer-motion"; import { motion, Variants } from "framer-motion";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient"; import RichTextClient from "./RichTextClient";
@@ -32,6 +32,7 @@ const fadeInUp: Variants = {
const About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => { useEffect(() => {
@@ -99,7 +100,7 @@ const About = () => {
variants={fadeInUp} variants={fadeInUp}
className="text-4xl md:text-5xl font-bold text-stone-900" className="text-4xl md:text-5xl font-bold text-stone-900"
> >
About Me {t("title")}
</motion.h2> </motion.h2>
<motion.div <motion.div
variants={fadeInUp} variants={fadeInUp}
@@ -109,26 +110,9 @@ const About = () => {
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" /> <RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : ( ) : (
<> <>
<p> <p>{t("p1")}</p>
Hi, I&apos;m Dennis a student and passionate self-hoster based <p>{t("p2")}</p>
in Osnabrück, Germany. <p>{t("p3")}</p>
</p>
<p>
I love building full-stack web applications with{" "}
<strong>Next.js</strong> and mobile apps with{" "}
<strong>Flutter</strong>. But what really excites me is{" "}
<strong>DevOps</strong>: I run my own infrastructure on{" "}
<strong>IONOS</strong> and <strong>OVHcloud</strong>, managing
everything with <strong>Docker Swarm</strong>,{" "}
<strong>Traefik</strong>, and automated CI/CD pipelines with my
own runners.
</p>
<p>
When I&apos;m not coding or tinkering with servers, you&apos;ll
find me <strong>gaming</strong>, <strong>jogging</strong>, or
experimenting with new tech like game servers or automation
workflows with <strong>n8n</strong>.
</p>
</> </>
)} )}
<motion.div <motion.div
@@ -139,12 +123,10 @@ const About = () => {
<Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" /> <Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" />
<div> <div>
<p className="text-sm font-semibold text-stone-800 mb-1"> <p className="text-sm font-semibold text-stone-800 mb-1">
Fun Fact {t("funFactTitle")}
</p> </p>
<p className="text-sm text-stone-700 leading-relaxed"> <p className="text-sm text-stone-700 leading-relaxed">
Even though I automate a lot, I still use pen and paper {t("funFactBody")}
for my calendar and notes it helps me clear my head and
stay focused.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1457,7 +1457,26 @@ export default function ActivityFeed() {
}; };
// Don't render if tracking is disabled and no data // Don't render if tracking is disabled and no data
if (!isTrackingEnabled && !data) return null; if (!isTrackingEnabled && !data) {
return (
<div className="fixed bottom-4 right-4 md:bottom-6 md:right-6 z-40 pointer-events-auto">
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-xl p-3 shadow-2xl"
>
<button
onClick={toggleTracking}
className="flex items-center gap-2 text-white/60 hover:text-white transition-colors"
title="Activity tracking is disabled. Click to enable."
>
<Activity size={16} />
<span className="text-xs">Tracking disabled</span>
</button>
</motion.div>
</div>
);
}
// If tracking disabled but we have data, show a disabled state // If tracking disabled but we have data, show a disabled state
if (!isTrackingEnabled && data) { if (!isTrackingEnabled && data) {

View File

@@ -38,9 +38,11 @@ function writeConsentCookie(value: ConsentState) {
const ConsentContext = createContext<{ const ConsentContext = createContext<{
consent: ConsentState | null; consent: ConsentState | null;
setConsent: (next: ConsentState) => void; setConsent: (next: ConsentState) => void;
resetConsent: () => void;
}>({ }>({
consent: null, consent: null,
setConsent: () => {}, setConsent: () => {},
resetConsent: () => {},
}); });
export function ConsentProvider({ children }: { children: React.ReactNode }) { export function ConsentProvider({ children }: { children: React.ReactNode }) {
@@ -55,7 +57,16 @@ export function ConsentProvider({ children }: { children: React.ReactNode }) {
writeConsentCookie(next); writeConsentCookie(next);
}, []); }, []);
const value = useMemo(() => ({ consent, setConsent }), [consent, setConsent]); const resetConsent = useCallback(() => {
setConsentState(null);
// expire cookie
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; samesite=lax`;
}, []);
const value = useMemo(
() => ({ consent, setConsent, resetConsent }),
[consent, setConsent, resetConsent],
);
return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>; return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>;
} }

View File

@@ -4,20 +4,16 @@ import { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Mail, MapPin, Send } from "lucide-react"; import { Mail, MapPin, Send } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient"; import RichTextClient from "./RichTextClient";
const Contact = () => { const Contact = () => {
const [mounted, setMounted] = useState(false);
const { showEmailSent, showEmailError } = useToast(); const { showEmailSent, showEmailError } = useToast();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.contact");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
@@ -164,10 +160,6 @@ const Contact = () => {
}, },
]; ];
if (!mounted) {
return null;
}
return ( return (
<section <section
id="contact" id="contact"
@@ -183,14 +175,13 @@ const Contact = () => {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900"> <h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
Contact Me {t("title")}
</h2> </h2>
{cmsDoc ? ( {cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" /> <RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
) : ( ) : (
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4"> <p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
Interested in working together or have questions about my projects? {t("subtitle")}
Feel free to reach out!
</p> </p>
)} )}
</motion.div> </motion.div>
@@ -206,12 +197,10 @@ const Contact = () => {
> >
<div> <div>
<h3 className="text-2xl font-bold text-stone-900 mb-6"> <h3 className="text-2xl font-bold text-stone-900 mb-6">
Get In Touch {t("getInTouch")}
</h3> </h3>
<p className="text-stone-700 leading-relaxed"> <p className="text-stone-700 leading-relaxed">
I&apos;m always available to discuss new opportunities, {t("getInTouchBody")}
interesting projects, or simply chat about technology and
innovation.
</p> </p>
</div> </div>

View File

@@ -6,15 +6,15 @@ import { Heart, Code } from 'lucide-react';
import { SiGithub, SiLinkedin } from 'react-icons/si'; import { SiGithub, SiLinkedin } from 'react-icons/si';
import Link from 'next/link'; import Link from 'next/link';
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { useConsent } from "./ConsentProvider";
const Footer = () => { const Footer = () => {
const [currentYear, setCurrentYear] = useState(2024); const [currentYear, setCurrentYear] = useState(2024);
const [mounted, setMounted] = useState(false);
const locale = useLocale(); const locale = useLocale();
const { resetConsent } = useConsent();
useEffect(() => { useEffect(() => {
setCurrentYear(new Date().getFullYear()); setCurrentYear(new Date().getFullYear());
setMounted(true);
}, []); }, []);
const socialLinks = [ const socialLinks = [
@@ -22,10 +22,6 @@ const Footer = () => {
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' } { icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
]; ];
if (!mounted) {
return null;
}
return ( return (
<footer className="relative py-12 px-4 bg-white border-t border-stone-200"> <footer className="relative py-12 px-4 bg-white border-t border-stone-200">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
@@ -117,6 +113,14 @@ const Footer = () => {
> >
Privacy Policy Privacy Policy
</Link> </Link>
<button
type="button"
onClick={() => resetConsent()}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
title="Show privacy settings banner again"
>
Privacy settings
</button>
<Link <Link
href="/404" href="/404"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs" className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"

View File

@@ -11,7 +11,6 @@ import { usePathname, useRouter } from "next/navigation";
const Header = () => { const Header = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [mounted, setMounted] = useState(false);
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@@ -19,13 +18,6 @@ const Header = () => {
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`; const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
// Use requestAnimationFrame to ensure smooth transition
requestAnimationFrame(() => {
setMounted(true);
});
}, []);
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
setScrolled(window.scrollY > 50); setScrolled(window.scrollY > 50);
@@ -69,10 +61,9 @@ const Header = () => {
<> <>
<motion.header <motion.header
initial={false} initial={false}
animate={{ y: 0, opacity: mounted ? 1 : 0 }} animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none" className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
style={{ opacity: mounted ? 1 : 0 }}
> >
<div <div
className={`pointer-events-auto transition-all duration-500 ease-out ${ className={`pointer-events-auto transition-all duration-500 ease-out ${
@@ -81,7 +72,7 @@ const Header = () => {
> >
<motion.div <motion.div
initial={false} initial={false}
animate={{ opacity: mounted ? 1 : 0, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.3, ease: "easeOut" }}
className={` className={`
backdrop-blur-xl transition-all duration-500 backdrop-blur-xl transition-all duration-500

View File

@@ -4,12 +4,13 @@ import { motion } from "framer-motion";
import { ArrowDown, Code, Zap, Rocket } from "lucide-react"; import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocale } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient"; import RichTextClient from "./RichTextClient";
const Hero = () => { const Hero = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.hero");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => { useEffect(() => {
@@ -29,9 +30,9 @@ const Hero = () => {
}, [locale]); }, [locale]);
const features = [ const features = [
{ icon: Code, text: "Next.js & Flutter" }, { icon: Code, text: t("features.f1") },
{ icon: Zap, text: "Docker Swarm & CI/CD" }, { icon: Zap, text: t("features.f2") },
{ icon: Rocket, text: "Self-Hosted Infrastructure" }, { icon: Rocket, text: t("features.f3") },
]; ];
return ( return (
@@ -178,21 +179,7 @@ const Hero = () => {
{cmsDoc ? ( {cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" /> <RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : ( ) : (
<p> <p>{t("description")}</p>
Student and passionate{" "}
<span className="text-stone-900 font-semibold decoration-liquid-mint decoration-2 underline underline-offset-4">
self-hoster
</span>{" "}
building full-stack web apps and mobile solutions. I run my own{" "}
<span className="text-stone-900 font-semibold decoration-liquid-lavender decoration-2 underline underline-offset-4">
infrastructure
</span>{" "}
and love exploring{" "}
<span className="text-stone-900 font-semibold decoration-liquid-rose decoration-2 underline underline-offset-4">
DevOps
</span>
.
</p>
)} )}
</motion.div> </motion.div>
@@ -238,7 +225,7 @@ const Hero = () => {
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.3, ease: "easeOut" }}
className="px-8 py-4 bg-stone-900 text-cream rounded-full shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2" className="px-8 py-4 bg-stone-900 text-cream rounded-full shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
> >
<span className="text-cream">View My Work</span> <span className="text-cream">{t("ctaWork")}</span>
<ArrowDown size={18} /> <ArrowDown size={18} />
</motion.a> </motion.a>
@@ -249,7 +236,7 @@ const Hero = () => {
transition={{ duration: 0.3, ease: "easeOut" }} transition={{ duration: 0.3, ease: "easeOut" }}
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500" className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500"
> >
<span>Contact Me</span> <span>{t("ctaContact")}</span>
</motion.a> </motion.a>
</motion.div> </motion.div>
</div> </div>

View File

@@ -48,6 +48,41 @@ jest.mock("next-intl", () => ({
}; };
return map[key] || key; return map[key] || key;
} }
if (namespace === "home.hero") {
const map: Record<string, string> = {
"features.f1": "Next.js & Flutter",
"features.f2": "Docker Swarm & CI/CD",
"features.f3": "Self-Hosted Infrastructure",
description:
"Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
ctaWork: "View My Work",
ctaContact: "Contact Me",
};
return map[key] || key;
}
if (namespace === "home.about") {
const map: Record<string, string> = {
title: "About Me",
p1: "Hi, I'm Dennis a student and passionate self-hoster based in Osnabrück, Germany.",
p2: "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
p3: "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
funFactTitle: "Fun Fact",
funFactBody:
"Even though I automate a lot, I still use pen and paper for my calendar and notes it helps me stay focused.",
};
return map[key] || key;
}
if (namespace === "home.contact") {
const map: Record<string, string> = {
title: "Contact Me",
subtitle:
"Interested in working together or have questions about my projects? Feel free to reach out!",
getInTouch: "Get In Touch",
getInTouchBody:
"I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
};
return map[key] || key;
}
return key; return key;
}, },
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => NextIntlClientProvider: ({ children }: { children: React.ReactNode }) =>

View File

@@ -21,5 +21,32 @@
"acceptSelected": "Auswahl akzeptieren", "acceptSelected": "Auswahl akzeptieren",
"rejectAll": "Alles ablehnen" "rejectAll": "Alles ablehnen"
} }
,
"home": {
"hero": {
"features": {
"f1": "Next.js & Flutter",
"f2": "Docker Swarm & CI/CD",
"f3": "Self-Hosted Infrastruktur"
},
"description": "Student und leidenschaftlicher Self-Hoster: Ich baue Full-Stack Web-Apps und Mobile-Lösungen, betreibe meine eigene Infrastruktur und liebe DevOps.",
"ctaWork": "Meine Projekte",
"ctaContact": "Kontakt"
},
"about": {
"title": "Über mich",
"p1": "Hi, ich bin Dennis Student und leidenschaftlicher Self-Hoster aus Osnabrück.",
"p2": "Ich entwickle Full-Stack Web-Apps mit Next.js und Mobile-Apps mit Flutter. Besonders spannend finde ich DevOps: eigene Infrastruktur, Automatisierung und CI/CD Deployments.",
"p3": "Wenn ich nicht code oder an Servern schraube, findest du mich beim Gaming, Joggen oder beim Experimentieren mit Automationen.",
"funFactTitle": "Fun Fact",
"funFactBody": "Auch wenn ich viel automatisiere, nutze ich für Kalender & Notizen noch Stift und Papier das hilft mir beim Fokus."
},
"contact": {
"title": "Kontakt",
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
"getInTouch": "Melde dich",
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk."
}
}
} }

View File

@@ -21,5 +21,32 @@
"acceptSelected": "Accept selected", "acceptSelected": "Accept selected",
"rejectAll": "Reject all" "rejectAll": "Reject all"
} }
,
"home": {
"hero": {
"features": {
"f1": "Next.js & Flutter",
"f2": "Docker Swarm & CI/CD",
"f3": "Self-Hosted Infrastructure"
},
"description": "Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
"ctaWork": "View My Work",
"ctaContact": "Contact Me"
},
"about": {
"title": "About Me",
"p1": "Hi, I'm Dennis a student and passionate self-hoster based in Osnabrück, Germany.",
"p2": "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
"p3": "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
"funFactTitle": "Fun Fact",
"funFactBody": "Even though I automate a lot, I still use pen and paper for my calendar and notes it helps me stay focused."
},
"contact": {
"title": "Contact Me",
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
"getInTouch": "Get In Touch",
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation."
}
}
} }

View File

@@ -20,6 +20,23 @@ function hasLocalePrefix(pathname: string): boolean {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
// If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg),
// redirect to the non-prefixed asset path.
if (hasLocalePrefix(pathname)) {
const rest = pathname.replace(/^\/(en|de)/, "") || "/";
if (rest.includes(".")) {
const responseUrl = request.nextUrl.clone();
responseUrl.pathname = rest;
const res = NextResponse.redirect(responseUrl);
return addHeaders(request, res);
}
}
// Do not locale-route public assets (anything with a dot), robots, sitemap, etc.
if (pathname.includes(".")) {
return addHeaders(request, NextResponse.next());
}
// Keep admin + APIs unlocalized for simplicity // Keep admin + APIs unlocalized for simplicity
const isAdminOrApi = const isAdminOrApi =
pathname.startsWith("/api/") || pathname.startsWith("/api/") ||
@@ -107,8 +124,7 @@ export const config = {
* - _next/static (static files) * - _next/static (static files)
* - _next/image (image optimization files) * - _next/image (image optimization files)
* - favicon.ico (favicon file) * - favicon.ico (favicon file)
* - any path containing a dot (public assets like /images/*.jpg, /robots.txt, /sitemap.xml, etc.)
*/ */
"/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)", "/((?!api|_next/static|_next/image|favicon.ico).*)",
], ],
}; };