From d297776c9f2f525a0f6ff00f73be4288f33fc1e7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:45:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Snippets=20"The=20Lab"=20=E2=80=94=20ca?= =?UTF-8?q?tegory=20filters,=20search,=20language=20badges,=20code=20previ?= =?UTF-8?q?ew,=20modal=20keyboard=20nav=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: pass locale explicitly to Hero and force-dynamic on locale-sensitive API routes - Hero.tsx: pass locale prop directly to getTranslations instead of relying on setRequestLocale async storage, which can be lost during Next.js RSC streaming - book-reviews route: replace revalidate=300 with force-dynamic to prevent cached English responses being served to German locale requests - content/page route: add runtime=nodejs and force-dynamic (was missing both, violating CLAUDE.md API route conventions) Co-Authored-By: Claude Sonnet 4.6 * fix: scroll to top on locale switch and remove dashes from hero text - HeaderClient: track locale prop changes with useRef and call window.scrollTo on switch to reliably reset scroll position - messages/en.json + de.json: replace em dash with comma and remove hyphens from Self-Hoster/Full-Stack in hero description Co-Authored-By: Claude Sonnet 4.6 * Initial plan * feat: improve Snippets/The Lab UI with category filters, search, language badges and code preview Agent-Logs-Url: https://github.com/denshooter/portfolio/sessions/4a562022-cad7-4b4f-8bc4-1be022ecbf1e Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> --------- Co-authored-by: denshooter Co-authored-by: Claude Sonnet 4.6 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com> --- app/[locale]/snippets/SnippetsClient.tsx | 279 +++++++++++++++++++---- app/api/book-reviews/route.ts | 2 +- app/api/content/page/route.ts | 3 + app/components/HeaderClient.tsx | 10 +- app/components/Hero.tsx | 4 +- messages/de.json | 2 +- messages/en.json | 2 +- package-lock.json | 11 - 8 files changed, 249 insertions(+), 64 deletions(-) diff --git a/app/[locale]/snippets/SnippetsClient.tsx b/app/[locale]/snippets/SnippetsClient.tsx index 869fc03..90abfc6 100644 --- a/app/[locale]/snippets/SnippetsClient.tsx +++ b/app/[locale]/snippets/SnippetsClient.tsx @@ -1,73 +1,242 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Snippet } from "@/lib/directus"; -import { X, Copy, Check, Hash } from "lucide-react"; +import { X, Copy, Check, ChevronLeft, ChevronRight, Search } from "lucide-react"; + +// Color-coded language badges using the liquid design palette +const LANG_STYLES: Record = { + typescript: { bg: "bg-liquid-lavender/40", text: "text-purple-700 dark:text-purple-300", label: "TS" }, + ts: { bg: "bg-liquid-lavender/40", text: "text-purple-700 dark:text-purple-300", label: "TS" }, + javascript: { bg: "bg-liquid-amber/40", text: "text-amber-700 dark:text-amber-300", label: "JS" }, + js: { bg: "bg-liquid-amber/40", text: "text-amber-700 dark:text-amber-300", label: "JS" }, + python: { bg: "bg-liquid-sky/40", text: "text-sky-700 dark:text-sky-300", label: "PY" }, + bash: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" }, + shell: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" }, + sh: { bg: "bg-liquid-mint/40", text: "text-emerald-700 dark:text-emerald-300", label: "SH" }, + dockerfile: { bg: "bg-liquid-blue/40", text: "text-blue-700 dark:text-blue-300", label: "🐳" }, + docker: { bg: "bg-liquid-blue/40", text: "text-blue-700 dark:text-blue-300", label: "🐳" }, + css: { bg: "bg-liquid-pink/40", text: "text-pink-700 dark:text-pink-300", label: "CSS" }, + scss: { bg: "bg-liquid-pink/40", text: "text-pink-700 dark:text-pink-300", label: "SCSS" }, + go: { bg: "bg-liquid-teal/40", text: "text-teal-700 dark:text-teal-300", label: "GO" }, + rust: { bg: "bg-liquid-peach/40", text: "text-orange-700 dark:text-orange-300", label: "RS" }, + yaml: { bg: "bg-liquid-lime/40", text: "text-lime-700 dark:text-lime-300", label: "YAML" }, + json: { bg: "bg-liquid-lime/40", text: "text-lime-700 dark:text-lime-300", label: "JSON" }, + sql: { bg: "bg-liquid-coral/40", text: "text-red-700 dark:text-red-300", label: "SQL" }, + nginx: { bg: "bg-liquid-teal/40", text: "text-teal-700 dark:text-teal-300", label: "NGINX" }, +}; + +function getLangStyle(language: string) { + return LANG_STYLES[language?.toLowerCase()] ?? { + bg: "bg-liquid-purple/30", + text: "text-purple-700 dark:text-purple-300", + label: language?.toUpperCase() || "CODE", + }; +} + +function CodePreview({ code }: { code: string }) { + const lines = code.split("\n").slice(0, 4); + return ( +
+      {lines.map((line, i) => (
+        
{line || " "}
+ ))} + {code.split("\n").length > 4 && ( +
+ )} +
+ ); +} export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) { const [selectedSnippet, setSelectedSnippet] = useState(null); const [copied, setCopied] = useState(false); + const [activeCategory, setActiveCategory] = useState("All"); + const [search, setSearch] = useState(""); - const copyToClipboard = (code: string) => { + // Derived data + const categories = useMemo(() => { + const cats = Array.from(new Set(initialSnippets.map((s) => s.category))).sort(); + return ["All", ...cats]; + }, [initialSnippets]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return initialSnippets.filter((s) => { + const matchCat = activeCategory === "All" || s.category === activeCategory; + const matchSearch = + !q || + s.title.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q) || + s.category.toLowerCase().includes(q) || + s.language.toLowerCase().includes(q); + return matchCat && matchSearch; + }); + }, [initialSnippets, activeCategory, search]); + + // Language badge for the currently open modal + const modalLang = useMemo( + () => (selectedSnippet ? getLangStyle(selectedSnippet.language) : null), + [selectedSnippet] + ); + + // Keyboard nav: ESC + arrows + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!selectedSnippet) return; + if (e.key === "Escape") { + setSelectedSnippet(null); + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { + const idx = filtered.findIndex((s) => s.id === selectedSnippet.id); + if (idx < filtered.length - 1) setSelectedSnippet(filtered[idx + 1]); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + const idx = filtered.findIndex((s) => s.id === selectedSnippet.id); + if (idx > 0) setSelectedSnippet(filtered[idx - 1]); + } + }, + [selectedSnippet, filtered] + ); + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const copyToClipboard = useCallback((code: string) => { navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); - }; + }, []); + + const currentIndex = selectedSnippet + ? filtered.findIndex((s) => s.id === selectedSnippet.id) + : -1; return ( <> -
- {initialSnippets.map((s, i) => ( - setSelectedSnippet(s)} - className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group" - > -
-
- -
- {s.category} -
-

{s.title}

-

- {s.description} -

-
- ))} + {/* ── Filter & Search bar ── */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl text-sm text-stone-900 dark:text-stone-100 placeholder:text-stone-400 focus:outline-none focus:border-liquid-purple transition-colors" + /> +
+ + {/* Category chips */} +
+ {categories.map((cat) => ( + + ))} +
- {/* Snippet Modal */} + {/* ── Empty state ── */} + {filtered.length === 0 && ( +

+ No snippets found{search ? ` for "${search}"` : ""}. +

+ )} + + {/* ── Snippet Grid ── */} +
+ {filtered.map((s, i) => { + const lang = getLangStyle(s.language); + return ( + setSelectedSnippet(s)} + className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group flex flex-col" + > + {/* Header row: category + language badge */} +
+ + {s.category} + + {s.language && ( + + {lang.label} + + )} +
+ + {/* Title */} +

+ {s.title} +

+ + {/* Description */} +

+ {s.description} +

+ + {/* Mini code preview */} + +
+ ); + })} +
+ + {/* ── Snippet Modal ── */} - {selectedSnippet && ( + {selectedSnippet && modalLang && (
- setSelectedSnippet(null)} className="absolute inset-0 bg-stone-950/60 backdrop-blur-md" /> -
-
-
-

{selectedSnippet.category}

-

{selectedSnippet.title}

+ {/* Modal header */} +
+
+
+

+ {selectedSnippet.category} +

+ {selectedSnippet.language && ( + + {modalLang.label} + + )} +
+

+ {selectedSnippet.title} +

- @@ -77,12 +246,13 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S {selectedSnippet.description}

-
-
- @@ -92,12 +262,27 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
-
- + + {currentIndex + 1} / {filtered.length} + +
diff --git a/app/api/book-reviews/route.ts b/app/api/book-reviews/route.ts index 549ae66..4a5a31d 100644 --- a/app/api/book-reviews/route.ts +++ b/app/api/book-reviews/route.ts @@ -3,7 +3,7 @@ import { getBookReviews } from '@/lib/directus'; import { checkRateLimit, getClientIp } from '@/lib/auth'; export const runtime = 'nodejs'; -export const revalidate = 300; +export const dynamic = 'force-dynamic'; const CACHE_TTL = 300; // 5 minutes diff --git a/app/api/content/page/route.ts b/app/api/content/page/route.ts index 4bdab1c..db35864 100644 --- a/app/api/content/page/route.ts +++ b/app/api/content/page/route.ts @@ -3,6 +3,9 @@ import { getContentByKey } from "@/lib/content"; import { getContentPage } from "@/lib/directus"; import { richTextToSafeHtml } from "@/lib/richtext"; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + const CACHE_TTL = 300; // 5 minutes export async function GET(request: NextRequest) { diff --git a/app/components/HeaderClient.tsx b/app/components/HeaderClient.tsx index 55bef47..e253c38 100644 --- a/app/components/HeaderClient.tsx +++ b/app/components/HeaderClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { SiGithub, SiLinkedin } from "react-icons/si"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; @@ -27,6 +27,14 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps const [scrolled, setScrolled] = useState(false); const pathname = usePathname(); const searchParams = useSearchParams(); + const prevLocale = useRef(locale); + + useEffect(() => { + if (prevLocale.current !== locale) { + window.scrollTo({ top: 0, behavior: "instant" }); + prevLocale.current = locale; + } + }, [locale]); const isHome = pathname === `/${locale}` || pathname === `/${locale}/`; diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 9337184..125cbb5 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -5,8 +5,8 @@ interface HeroProps { locale: string; } -export default async function Hero({ locale: _locale }: HeroProps) { - const t = await getTranslations("home.hero"); +export default async function Hero({ locale }: HeroProps) { + const t = await getTranslations({ locale, namespace: "home.hero" }); return (
diff --git a/messages/de.json b/messages/de.json index 72e5f21..b5f6766 100644 --- a/messages/de.json +++ b/messages/de.json @@ -34,7 +34,7 @@ "f2": "Docker Swarm & CI/CD", "f3": "Self-Hosted Infrastruktur" }, - "description": "Ich bin Dennis – Student aus Osnabrück und leidenschaftlicher Self-Hoster. Ich entwickle Full-Stack Apps und sorge am liebsten selbst dafür, dass sie auf meiner eigenen Infrastruktur perfekt laufen.", + "description": "Ich bin Dennis, Student aus Osnabrück und leidenschaftlicher Selfhoster. Ich entwickle Fullstack Apps und sorge am liebsten selbst dafür, dass sie auf meiner eigenen Infrastruktur perfekt laufen.", "ctaWork": "Meine Projekte", "ctaContact": "Kontakt" }, diff --git a/messages/en.json b/messages/en.json index d319dd4..4eb42b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -35,7 +35,7 @@ "f2": "Docker Swarm & CI/CD", "f3": "Self-Hosted Infrastructure" }, - "description": "I'm Dennis – a student from Germany and a passionate self-hoster. I build full-stack applications and love the challenge of managing the infrastructure they run on.", + "description": "I'm Dennis, a student from Germany and a passionate selfhoster. I build fullstack applications and love the challenge of managing the infrastructure they run on.", "ctaWork": "View Projects", "ctaContact": "Get in touch" }, diff --git a/package-lock.json b/package-lock.json index 7512de5..e44a13c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "lucide-react": "^0.542.0", "next": "^15.5.7", "next-intl": "^4.7.0", - "next-themes": "^0.4.6", "node-cache": "^5.1.2", "node-fetch": "^2.7.0", "nodemailer": "^7.0.11", @@ -11348,16 +11347,6 @@ } } }, - "node_modules/next-themes": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/next/node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",