feat: Snippets "The Lab" — category filters, search, language badges, code preview, modal keyboard nav (#72)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <dennis@konkol.net> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
This commit is contained in:
@@ -1,50 +1,205 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Snippet } from "@/lib/directus";
|
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<string, { bg: string; text: string; label: string }> = {
|
||||||
|
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 (
|
||||||
|
<pre className="mt-4 bg-stone-950/80 rounded-xl p-3 text-[11px] font-mono text-stone-400 overflow-hidden leading-relaxed border border-stone-800/60 select-none">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} className="truncate">{line || " "}</div>
|
||||||
|
))}
|
||||||
|
{code.split("\n").length > 4 && (
|
||||||
|
<div className="text-stone-600 text-[10px] mt-1">…</div>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
||||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>("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);
|
navigator.clipboard.writeText(code);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const currentIndex = selectedSnippet
|
||||||
|
? filtered.findIndex((s) => s.id === selectedSnippet.id)
|
||||||
|
: -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
{/* ── Filter & Search bar ── */}
|
||||||
{initialSnippets.map((s, i) => (
|
<div className="flex flex-col sm:flex-row gap-4 mb-10">
|
||||||
<motion.button
|
{/* Search */}
|
||||||
key={s.id}
|
<div className="relative flex-1 max-w-sm">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<input
|
||||||
transition={{ delay: i * 0.05 }}
|
type="text"
|
||||||
onClick={() => setSelectedSnippet(s)}
|
placeholder="Search snippets…"
|
||||||
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"
|
value={search}
|
||||||
>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
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"
|
||||||
<div className="w-8 h-8 rounded-xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center text-stone-400 group-hover:text-liquid-purple transition-colors">
|
/>
|
||||||
<Hash size={16} />
|
</div>
|
||||||
</div>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
|
{/* Category chips */}
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<h3 className="text-2xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-4 group-hover:text-liquid-purple transition-colors">{s.title}</h3>
|
{categories.map((cat) => (
|
||||||
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
|
<button
|
||||||
{s.description}
|
key={cat}
|
||||||
</p>
|
onClick={() => setActiveCategory(cat)}
|
||||||
</motion.button>
|
className={`px-4 py-2 rounded-2xl text-[11px] font-black uppercase tracking-widest border transition-all ${
|
||||||
))}
|
activeCategory === cat
|
||||||
|
? "bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 border-stone-900 dark:border-stone-50 shadow-md"
|
||||||
|
: "bg-white dark:bg-stone-900 text-stone-500 dark:text-stone-400 border-stone-200 dark:border-stone-800 hover:border-liquid-purple hover:text-liquid-purple"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Snippet Modal */}
|
{/* ── Empty state ── */}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-center text-stone-400 py-24 text-sm">
|
||||||
|
No snippets found{search ? ` for "${search}"` : ""}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Snippet Grid ── */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{filtered.map((s, i) => {
|
||||||
|
const lang = getLangStyle(s.language);
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={s.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.04 }}
|
||||||
|
onClick={() => 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 */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400 group-hover:text-liquid-purple transition-colors">
|
||||||
|
{s.category}
|
||||||
|
</span>
|
||||||
|
{s.language && (
|
||||||
|
<span className={`px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-wider ${lang.bg} ${lang.text}`}>
|
||||||
|
{lang.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-2 group-hover:text-liquid-purple transition-colors leading-tight">
|
||||||
|
{s.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed flex-1">
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Mini code preview */}
|
||||||
|
<CodePreview code={s.code} />
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Snippet Modal ── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{selectedSnippet && (
|
{selectedSnippet && modalLang && (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -54,20 +209,34 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
|
|||||||
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
key={selectedSnippet.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 16 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 16 }}
|
||||||
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||||
>
|
>
|
||||||
<div className="p-8 md:p-10 overflow-y-auto">
|
<div className="p-8 md:p-10 overflow-y-auto">
|
||||||
<div className="flex justify-between items-start mb-8">
|
{/* Modal header */}
|
||||||
<div>
|
<div className="flex justify-between items-start mb-6">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple">
|
||||||
|
{selectedSnippet.category}
|
||||||
|
</p>
|
||||||
|
{selectedSnippet.language && (
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-wider ${modalLang.bg} ${modalLang.text}`}>
|
||||||
|
{modalLang.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter leading-tight">
|
||||||
|
{selectedSnippet.title}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedSnippet(null)}
|
onClick={() => setSelectedSnippet(null)}
|
||||||
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors shrink-0"
|
||||||
|
title="Close (Esc)"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -77,12 +246,13 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
|
|||||||
{selectedSnippet.description}
|
{selectedSnippet.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="relative group/code">
|
{/* Code block */}
|
||||||
<div className="absolute top-4 right-4 flex gap-2">
|
<div className="relative">
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2 z-10">
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(selectedSnippet.code)}
|
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||||
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||||
title="Copy Code"
|
title="Copy code"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -92,12 +262,27 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
|
||||||
|
{/* Modal footer: navigation */}
|
||||||
|
<div className="px-8 py-5 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedSnippet(null)}
|
onClick={() => currentIndex > 0 && setSelectedSnippet(filtered[currentIndex - 1])}
|
||||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
disabled={currentIndex <= 0}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Previous (←)"
|
||||||
>
|
>
|
||||||
Close Laboratory
|
<ChevronLeft size={14} /> Prev
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-300 dark:text-stone-600 tabular-nums">
|
||||||
|
{currentIndex + 1} / {filtered.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => currentIndex < filtered.length - 1 && setSelectedSnippet(filtered[currentIndex + 1])}
|
||||||
|
disabled={currentIndex >= filtered.length - 1}
|
||||||
|
className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="Next (→)"
|
||||||
|
>
|
||||||
|
Next <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getBookReviews } from '@/lib/directus';
|
|||||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const revalidate = 300;
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const CACHE_TTL = 300; // 5 minutes
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { getContentByKey } from "@/lib/content";
|
|||||||
import { getContentPage } from "@/lib/directus";
|
import { getContentPage } from "@/lib/directus";
|
||||||
import { richTextToSafeHtml } from "@/lib/richtext";
|
import { richTextToSafeHtml } from "@/lib/richtext";
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const CACHE_TTL = 300; // 5 minutes
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "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 { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
@@ -27,6 +27,14 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
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}/`;
|
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ interface HeroProps {
|
|||||||
locale: string;
|
locale: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Hero({ locale: _locale }: HeroProps) {
|
export default async function Hero({ locale }: HeroProps) {
|
||||||
const t = await getTranslations("home.hero");
|
const t = await getTranslations({ locale, namespace: "home.hero" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
<section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
"f3": "Self-Hosted Infrastruktur"
|
"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",
|
"ctaWork": "Meine Projekte",
|
||||||
"ctaContact": "Kontakt"
|
"ctaContact": "Kontakt"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
"f3": "Self-Hosted Infrastructure"
|
"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",
|
"ctaWork": "View Projects",
|
||||||
"ctaContact": "Get in touch"
|
"ctaContact": "Get in touch"
|
||||||
},
|
},
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -26,7 +26,6 @@
|
|||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"next": "^15.5.7",
|
"next": "^15.5.7",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.7.0",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.11",
|
"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": {
|
"node_modules/next/node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user