Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -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'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'm not coding or tinkering with servers, you'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'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'm not coding or tinkering with servers, you'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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
108
app/components/ConsentBanner.tsx
Normal file
108
app/components/ConsentBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
68
app/components/ConsentProvider.tsx
Normal file
68
app/components/ConsentProvider.tsx
Normal 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
21
app/components/RichText.tsx
Normal file
21
app/components/RichText.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
24
app/components/RichTextClient.tsx
Normal file
24
app/components/RichTextClient.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user