Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-12 14:36:10 +00:00
parent 0349c686fa
commit 12245eec8e
55 changed files with 4573 additions and 753 deletions

View File

@@ -2,6 +2,10 @@
import { motion, Variants } from "framer-motion";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
const staggerContainer: Variants = {
hidden: { opacity: 0 },
@@ -27,6 +31,25 @@ const fadeInUp: Variants = {
};
const About = () => {
const locale = useLocale();
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.content) {
setCmsDoc(data.content.content as JSONContent);
}
} catch {
// ignore; fallback to static
}
})();
}, [locale]);
const techStack = [
{
category: "Frontend & Mobile",
@@ -82,26 +105,32 @@ const About = () => {
variants={fadeInUp}
className="prose prose-stone prose-lg text-stone-700 space-y-4"
>
<p>
Hi, I&apos;m Dennis a student and passionate self-hoster based
in Osnabrück, Germany.
</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>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : (
<>
<p>
Hi, I&apos;m Dennis a student and passionate self-hoster based
in Osnabrück, Germany.
</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
variants={fadeInUp}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-lavender/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm"

View File

@@ -6,6 +6,8 @@ import dynamic from "next/dynamic";
import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ConsentProvider, useConsent } from "./ConsentProvider";
import ConsentBanner from "./ConsentBanner";
// Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
@@ -70,16 +72,44 @@ export default function ClientProviders({
return (
<ErrorBoundary>
<ErrorBoundary>
<AnalyticsProvider>
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
<div className="relative z-10">{children}</div>
{mounted && !is404Page && <ChatWidget />}
</ToastProvider>
</ErrorBoundary>
</AnalyticsProvider>
<ConsentProvider>
<GatedProviders mounted={mounted} is404Page={is404Page}>
{children}
</GatedProviders>
</ConsentProvider>
</ErrorBoundary>
</ErrorBoundary>
);
}
function GatedProviders({
children,
mounted,
is404Page,
}: {
children: React.ReactNode;
mounted: boolean;
is404Page: boolean;
}) {
const { consent } = useConsent();
const pathname = usePathname();
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor");
// If consent is not decided yet, treat optional features as off
const analyticsEnabled = !!consent?.analytics;
const chatEnabled = !!consent?.chat;
const content = (
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
<div className="relative z-10">{children}</div>
{mounted && !isAdminRoute && <ConsentBanner />}
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
</ToastProvider>
</ErrorBoundary>
);
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
}

View File

@@ -0,0 +1,108 @@
"use client";
import React, { useMemo, useState } from "react";
import { useConsent, type ConsentState } from "./ConsentProvider";
export default function ConsentBanner() {
const { consent, setConsent } = useConsent();
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
const shouldShow = useMemo(() => consent === null, [consent]);
if (!shouldShow) return null;
const locale = useMemo(() => {
if (typeof document === "undefined") return "en";
const match = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("NEXT_LOCALE="));
if (!match) return "en";
return decodeURIComponent(match.split("=").slice(1).join("=")) || "en";
}, []);
const s = locale === "de"
? {
title: "Datenschutz-Einstellungen",
description:
"Wir nutzen optionale Dienste (Analytics und Chat), um die Seite zu verbessern. Du kannst deine Auswahl jederzeit ändern.",
essential: "Essentiell",
analytics: "Analytics",
chat: "Chatbot",
acceptAll: "Alles akzeptieren",
acceptSelected: "Auswahl akzeptieren",
rejectAll: "Alles ablehnen",
}
: {
title: "Privacy settings",
description:
"We use optional services (analytics and chat) to improve the site. You can change your choice anytime.",
essential: "Essential",
analytics: "Analytics",
chat: "Chatbot",
acceptAll: "Accept all",
acceptSelected: "Accept selected",
rejectAll: "Reject all",
};
return (
<div className="fixed bottom-4 left-4 right-4 z-[60]">
<div className="max-w-3xl mx-auto bg-white/95 backdrop-blur-xl border border-stone-200 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.18)] p-5">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="min-w-0">
<div className="text-lg font-bold text-stone-900">{s.title}</div>
<p className="text-sm text-stone-600 mt-1">{s.description}</p>
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-stone-800">{s.essential}</div>
<div className="text-xs text-stone-500">Always on</div>
</div>
<label className="flex items-center justify-between gap-3 py-1">
<span className="text-sm font-medium text-stone-800">{s.analytics}</span>
<input
type="checkbox"
checked={draft.analytics}
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
className="w-4 h-4 accent-stone-900"
/>
</label>
<label className="flex items-center justify-between gap-3 py-1">
<span className="text-sm font-medium text-stone-800">{s.chat}</span>
<input
type="checkbox"
checked={draft.chat}
onChange={(e) => setDraft((p) => ({ ...p, chat: e.target.checked }))}
className="w-4 h-4 accent-stone-900"
/>
</label>
</div>
</div>
<div className="flex flex-col gap-2 shrink-0">
<button
onClick={() => setConsent({ analytics: true, chat: true })}
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
>
{s.acceptAll}
</button>
<button
onClick={() => setConsent(draft)}
className="px-4 py-2 rounded-xl bg-white border border-stone-200 text-stone-800 font-semibold hover:bg-stone-50 transition-colors"
>
{s.acceptSelected}
</button>
<button
onClick={() => setConsent({ analytics: false, chat: false })}
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
>
{s.rejectAll}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
export type ConsentState = {
analytics: boolean;
chat: boolean;
};
const COOKIE_NAME = "dk0_consent_v1";
function readConsentFromCookie(): ConsentState | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${COOKIE_NAME}=`));
if (!match) return null;
const value = decodeURIComponent(match.split("=").slice(1).join("="));
try {
const parsed = JSON.parse(value) as Partial<ConsentState>;
return {
analytics: !!parsed.analytics,
chat: !!parsed.chat,
};
} catch {
return null;
}
}
function writeConsentCookie(value: ConsentState) {
const encoded = encodeURIComponent(JSON.stringify(value));
// 180 days
const maxAge = 60 * 60 * 24 * 180;
document.cookie = `${COOKIE_NAME}=${encoded}; path=/; max-age=${maxAge}; samesite=lax`;
}
const ConsentContext = createContext<{
consent: ConsentState | null;
setConsent: (next: ConsentState) => void;
}>({
consent: null,
setConsent: () => {},
});
export function ConsentProvider({ children }: { children: React.ReactNode }) {
const [consent, setConsentState] = useState<ConsentState | null>(null);
useEffect(() => {
setConsentState(readConsentFromCookie());
}, []);
const setConsent = useCallback((next: ConsentState) => {
setConsentState(next);
writeConsentCookie(next);
}, []);
const value = useMemo(() => ({ consent, setConsent }), [consent, setConsent]);
return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>;
}
export function useConsent() {
return useContext(ConsentContext);
}
export const consentCookieName = COOKIE_NAME;

View File

@@ -4,15 +4,36 @@ import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Mail, MapPin, Send } from "lucide-react";
import { useToast } from "@/components/Toast";
import { useLocale } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
const Contact = () => {
const [mounted, setMounted] = useState(false);
const { showEmailSent, showEmailError } = useToast();
const locale = useLocale();
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-contact")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.content) {
setCmsDoc(data.content.content as JSONContent);
}
} catch {
// ignore; fallback to static
}
})();
}, [locale]);
const [formData, setFormData] = useState({
name: "",
email: "",
@@ -164,10 +185,14 @@ const Contact = () => {
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
Contact Me
</h2>
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
Interested in working together or have questions about my projects?
Feel free to reach out!
</p>
{cmsDoc ? (
<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">
Interested in working together or have questions about my projects?
Feel free to reach out!
</p>
)}
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">

View File

@@ -5,10 +5,12 @@ import { motion } from 'framer-motion';
import { Heart, Code } from 'lucide-react';
import { SiGithub, SiLinkedin } from 'react-icons/si';
import Link from 'next/link';
import { useLocale } from "next-intl";
const Footer = () => {
const [currentYear, setCurrentYear] = useState(2024);
const [mounted, setMounted] = useState(false);
const locale = useLocale();
useEffect(() => {
setCurrentYear(new Date().getFullYear());
@@ -44,7 +46,7 @@ const Footer = () => {
<Code className="w-6 h-6 text-stone-800" />
</motion.div>
<div>
<Link href="/" className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
<Link href={`/${locale}`} className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
dk<span className="text-liquid-rose">0</span>
</Link>
<p className="text-xs text-stone-500">Software Engineer</p>
@@ -104,13 +106,13 @@ const Footer = () => {
>
<div className="flex space-x-6 text-sm">
<Link
href="/legal-notice"
href={`/${locale}/legal-notice`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
Impressum
</Link>
<Link
href="/privacy-policy"
href={`/${locale}/privacy-policy`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
Privacy Policy

View File

@@ -5,11 +5,19 @@ import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
const Header = () => {
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [mounted, setMounted] = useState(false);
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
// Use requestAnimationFrame to ensure smooth transition
@@ -28,10 +36,10 @@ const Header = () => {
}, []);
const navItems = [
{ name: "Home", href: "/" },
{ name: "About", href: "#about" },
{ name: "Projects", href: "#projects" },
{ name: "Contact", href: "#contact" },
{ name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
];
const socialLinks = [
@@ -44,6 +52,17 @@ const Header = () => {
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
];
const switchLocale = (nextLocale: string) => {
try {
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
const hash = typeof window !== "undefined" ? window.location.hash : "";
router.push(`/${nextLocale}${pathWithoutLocale}${hash}`);
document.cookie = `NEXT_LOCALE=${nextLocale}; path=/`;
} catch {
// ignore
}
};
// Always render to prevent flash, but use opacity transition
return (
@@ -79,7 +98,7 @@ const Header = () => {
className="flex items-center space-x-2"
>
<Link
href="/"
href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
>
dk<span className="text-red-500">0</span>
@@ -126,6 +145,32 @@ const Header = () => {
</nav>
<div className="hidden md:flex items-center space-x-3">
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
<button
type="button"
onClick={() => switchLocale("en")}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "en"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Switch language to English"
>
EN
</button>
<button
type="button"
onClick={() => switchLocale("de")}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "de"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Sprache auf Deutsch umstellen"
>
DE
</button>
</div>
{socialLinks.map((social) => (
<motion.a
key={social.label}
@@ -145,6 +190,7 @@ const Header = () => {
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover"
aria-label={isOpen ? "Close menu" : "Open menu"}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>

View File

@@ -3,8 +3,31 @@
import { motion } from "framer-motion";
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useLocale } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
const Hero = () => {
const locale = useLocale();
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.content) {
setCmsDoc(data.content.content as JSONContent);
}
} catch {
// ignore; fallback to static
}
})();
}, [locale]);
const features = [
{ icon: Code, text: "Next.js & Flutter" },
{ icon: Zap, text: "Docker Swarm & CI/CD" },
@@ -146,26 +169,32 @@ const Hero = () => {
</motion.div>
{/* Description */}
<motion.p
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
>
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>
.
</motion.p>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : (
<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>
{/* Features */}
<motion.div

View File

@@ -5,6 +5,7 @@ import { motion, Variants } from "framer-motion";
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useLocale } from "next-intl";
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
@@ -31,6 +32,7 @@ const staggerContainer: Variants = {
interface Project {
id: number;
slug: string;
title: string;
description: string;
content: string;
@@ -45,6 +47,7 @@ interface Project {
const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]);
const locale = useLocale();
useEffect(() => {
const loadProjects = async () => {
@@ -175,7 +178,7 @@ const Projects = () => {
<div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/projects/${project.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
@@ -247,7 +250,7 @@ const Projects = () => {
className="mt-16 text-center"
>
<Link
href="/projects"
href={`/${locale}/projects`}
className="inline-flex items-center gap-2 px-8 py-4 bg-white border border-stone-200 rounded-full text-stone-700 font-medium hover:bg-stone-50 hover:border-stone-300 hover:gap-3 transition-all duration-500 ease-out shadow-sm hover:shadow-md"
>
View All Projects <ArrowRight size={16} />

View File

@@ -0,0 +1,21 @@
import React from "react";
import type { JSONContent } from "@tiptap/react";
import { richTextToSafeHtml } from "@/lib/richtext";
export default function RichText({
doc,
className,
}: {
doc: JSONContent;
className?: string;
}) {
const html = richTextToSafeHtml(doc);
return (
<div
className={className}
// HTML is sanitized in `richTextToSafeHtml`
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import React, { useMemo } from "react";
import type { JSONContent } from "@tiptap/react";
import { richTextToSafeHtml } from "@/lib/richtext";
export default function RichTextClient({
doc,
className,
}: {
doc: JSONContent;
className?: string;
}) {
const html = useMemo(() => richTextToSafeHtml(doc), [doc]);
return (
<div
className={className}
// HTML is sanitized in `richTextToSafeHtml`
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}