* 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>
295 lines
14 KiB
TypeScript
295 lines
14 KiB
TypeScript
|
|
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Snippet } from "@/lib/directus";
|
|
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[] }) {
|
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [activeCategory, setActiveCategory] = useState<string>("All");
|
|
const [search, setSearch] = useState("");
|
|
|
|
// 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 (
|
|
<>
|
|
{/* ── Filter & Search bar ── */}
|
|
<div className="flex flex-col sm:flex-row gap-4 mb-10">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400 pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search snippets…"
|
|
value={search}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category chips */}
|
|
<div className="flex flex-wrap gap-2">
|
|
{categories.map((cat) => (
|
|
<button
|
|
key={cat}
|
|
onClick={() => setActiveCategory(cat)}
|
|
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>
|
|
|
|
{/* ── 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>
|
|
{selectedSnippet && modalLang && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setSelectedSnippet(null)}
|
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
|
/>
|
|
<motion.div
|
|
key={selectedSnippet.id}
|
|
initial={{ opacity: 0, scale: 0.95, y: 16 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
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]"
|
|
>
|
|
<div className="p-8 md:p-10 overflow-y-auto">
|
|
{/* Modal header */}
|
|
<div className="flex justify-between items-start mb-6">
|
|
<div className="flex-1 min-w-0 pr-4">
|
|
<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>
|
|
<button
|
|
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 shrink-0"
|
|
title="Close (Esc)"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
|
{selectedSnippet.description}
|
|
</p>
|
|
|
|
{/* Code block */}
|
|
<div className="relative">
|
|
<div className="absolute top-4 right-4 flex gap-2 z-10">
|
|
<button
|
|
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"
|
|
title="Copy code"
|
|
>
|
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
|
</button>
|
|
</div>
|
|
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
|
<code>{selectedSnippet.code}</code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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
|
|
onClick={() => currentIndex > 0 && setSelectedSnippet(filtered[currentIndex - 1])}
|
|
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 (←)"
|
|
>
|
|
<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>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|