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,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<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("");
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{initialSnippets.map((s, i) => (
|
||||
<motion.button
|
||||
key={s.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<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>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
|
||||
</div>
|
||||
<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>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
|
||||
{s.description}
|
||||
</p>
|
||||
</motion.button>
|
||||
))}
|
||||
{/* ── 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>
|
||||
|
||||
{/* 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>
|
||||
{selectedSnippet && (
|
||||
{selectedSnippet && modalLang && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||
<motion.div
|
||||
<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
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
<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.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]"
|
||||
>
|
||||
<div className="p-8 md:p-10 overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
||||
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||
{/* 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
|
||||
<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"
|
||||
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>
|
||||
@@ -77,12 +246,13 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
|
||||
{selectedSnippet.description}
|
||||
</p>
|
||||
|
||||
<div className="relative group/code">
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button
|
||||
{/* 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"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
@@ -92,12 +262,27 @@ export default function SnippetsClient({ initialSnippets }: { initialSnippets: S
|
||||
</pre>
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
|
||||
{/* 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 (←)"
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user