diff --git a/app/api/content/page/route.ts b/app/api/content/page/route.ts index 4e89980..4bdab1c 100644 --- a/app/api/content/page/route.ts +++ b/app/api/content/page/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getContentByKey } from "@/lib/content"; import { getContentPage } from "@/lib/directus"; +import { richTextToSafeHtml } from "@/lib/richtext"; const CACHE_TTL = 300; // 5 minutes @@ -17,6 +18,8 @@ export async function GET(request: NextRequest) { // 1) Try Directus first const directusPage = await getContentPage(key, locale); if (directusPage) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : ""; return NextResponse.json( { content: { @@ -24,6 +27,7 @@ export async function GET(request: NextRequest) { slug: directusPage.slug, locale: directusPage.locale || locale, content: directusPage.content, + html, }, source: "directus", }, diff --git a/app/components/About.tsx b/app/components/About.tsx index 520d059..a6a523f 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -3,7 +3,6 @@ import { useState, useEffect } from "react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); import CurrentlyReading from "./CurrentlyReading"; @@ -23,7 +22,7 @@ const iconMap: Record = { const About = () => { const locale = useLocale(); const t = useTranslations("home.about"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); const [techStack, setTechStack] = useState([]); const [hobbies, setHobbies] = useState([]); const [snippets, setSnippets] = useState([]); @@ -44,7 +43,7 @@ const About = () => { ]); const cmsData = await cmsRes.json(); - if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent); + if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string); const techData = await techRes.json(); if (techData?.techStack) setTechStack(techData.techStack); @@ -93,8 +92,8 @@ const About = () => { - ) : cmsDoc ? ( - + ) : cmsHtml ? ( + ) : (

{t("p1")} {t("p2")}

)} diff --git a/app/components/ClientWrappers.tsx b/app/components/ClientWrappers.tsx index 5b943eb..70d6b6e 100644 --- a/app/components/ClientWrappers.tsx +++ b/app/components/ClientWrappers.tsx @@ -6,10 +6,14 @@ */ import { NextIntlClientProvider } from 'next-intl'; -import About from './About'; -import Projects from './Projects'; -import Contact from './Contact'; -import Footer from './Footer'; +import dynamic from 'next/dynamic'; + +// Lazy-load below-fold components so their JS doesn't block initial paint / LCP. +// SSR stays on (default) so content is in the initial HTML for SEO. +const About = dynamic(() => import('./About')); +const Projects = dynamic(() => import('./Projects')); +const Contact = dynamic(() => import('./Contact')); +const Footer = dynamic(() => import('./Footer')); import type { AboutTranslations, ProjectsTranslations, diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 35202b0..0fed48b 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -5,7 +5,6 @@ import { motion } from "framer-motion"; import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react"; import { useToast } from "@/components/Toast"; import { useLocale, useTranslations } from "next-intl"; -import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); @@ -15,7 +14,7 @@ const Contact = () => { const t = useTranslations("home.contact"); const tForm = useTranslations("home.contact.form"); const tInfo = useTranslations("home.contact.info"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -25,14 +24,14 @@ const Contact = () => { ); const data = await res.json(); // Only use CMS content if it exists for the active locale. - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } else { - setCmsDoc(null); + setCmsHtml(null); } } catch { // ignore; fallback to static - setCmsDoc(null); + setCmsHtml(null); } })(); }, [locale]); @@ -169,8 +168,8 @@ const Contact = () => {

{t("title")}.

- {cmsDoc ? ( - + {cmsHtml ? ( + ) : (

{t("subtitle")} diff --git a/app/components/RichTextClient.tsx b/app/components/RichTextClient.tsx index 1813c51..e9abf7d 100644 --- a/app/components/RichTextClient.tsx +++ b/app/components/RichTextClient.tsx @@ -1,22 +1,19 @@ "use client"; -import React, { useMemo } from "react"; -import type { JSONContent } from "@tiptap/react"; -import { richTextToSafeHtml } from "@/lib/richtext"; +import React from "react"; +// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml). +// This keeps TipTap/ProseMirror out of the client bundle entirely. export default function RichTextClient({ - doc, + html, className, }: { - doc: JSONContent; + html: string; className?: string; }) { - const html = useMemo(() => richTextToSafeHtml(doc), [doc]); - return (

); diff --git a/app/legal-notice/page.tsx b/app/legal-notice/page.tsx index 9278a9f..31e55b0 100644 --- a/app/legal-notice/page.tsx +++ b/app/legal-notice/page.tsx @@ -8,13 +8,12 @@ import Footer from "../components/Footer"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import type { JSONContent } from "@tiptap/react"; import RichTextClient from "../components/RichTextClient"; export default function LegalNotice() { const locale = useLocale(); const t = useTranslations("common"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -23,8 +22,8 @@ export default function LegalNotice() { `/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`, ); const data = await res.json(); - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } } catch {} })(); @@ -64,9 +63,9 @@ export default function LegalNotice() { transition={{ delay: 0.1 }} className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" > - {cmsDoc ? ( + {cmsHtml ? (
- +
) : (
diff --git a/app/privacy-policy/page.tsx b/app/privacy-policy/page.tsx index 8579224..e0648ab 100644 --- a/app/privacy-policy/page.tsx +++ b/app/privacy-policy/page.tsx @@ -8,13 +8,12 @@ import Footer from "../components/Footer"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useState } from "react"; -import type { JSONContent } from "@tiptap/react"; import RichTextClient from "../components/RichTextClient"; export default function PrivacyPolicy() { const locale = useLocale(); const t = useTranslations("common"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -23,8 +22,8 @@ export default function PrivacyPolicy() { `/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`, ); const data = await res.json(); - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } } catch {} })(); @@ -64,9 +63,9 @@ export default function PrivacyPolicy() { transition={{ delay: 0.1 }} className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" > - {cmsDoc ? ( + {cmsHtml ? (
- +
) : (