feat: add Cmd+K command palette with actions and keyboard navigation

This commit is contained in:
Chneemann 2026-07-28 08:26:28 +02:00
parent afb1622563
commit 1d15fb28c3
4 changed files with 357 additions and 18 deletions

View file

@ -4,7 +4,9 @@ import "./globals.css";
import Header from "../components/Header"; import Header from "../components/Header";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Background from "../components/Background"; import Background from "../components/Background";
import CommandPalette from "../components/CommandPalette";
// Load Google Fonts as CSS custom variables
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
@ -19,6 +21,9 @@ const geistMono = Geist_Mono({
preload: false, preload: false,
}); });
/**
* Global page metadata and branding icons
*/
export const metadata: Metadata = { export const metadata: Metadata = {
title: "André Kempf | Portfolio", title: "André Kempf | Portfolio",
description: "My personal portfolio, built with React (Next.js)", description: "My personal portfolio, built with React (Next.js)",
@ -29,6 +34,9 @@ export const metadata: Metadata = {
}, },
}; };
/**
* Root application layout providing global font variables, shell UI, and persistent overlays
*/
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@ -37,6 +45,7 @@ export default function RootLayout({
return ( return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body> <body>
<CommandPalette />
<Background /> <Background />
<Header /> <Header />
<main className="grow">{children}</main> <main className="grow">{children}</main>

View file

@ -0,0 +1,192 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { getCommands } from "../lib/paletteCommands";
/**
* Interactive Cmd+K modal for quick navigation, actions, and social links
*/
export default function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [copied, setCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Load action definitions from lib helper
const commands = getCommands(copied, setCopied);
// Filter commands dynamically by search query or category
const filteredCommands = commands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(search.toLowerCase()) ||
cmd.category.toLowerCase().includes(search.toLowerCase()),
);
// Global hotkeys listener (Cmd/Ctrl + K to toggle, ESC to close)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
window.dispatchEvent(new CustomEvent("toggle-cmd-k"));
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
const handleCustomToggle = () => setIsOpen((prev) => !prev);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("toggle-cmd-k", handleCustomToggle);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("toggle-cmd-k", handleCustomToggle);
};
}, []);
// Reset input and focus search field when opened
useEffect(() => {
if (isOpen) {
setSearch("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 30);
}
}, [isOpen]);
// Keyboard navigation for command selection (Up/Down/Enter)
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % (filteredCommands.length || 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) =>
prev === 0 ? filteredCommands.length - 1 : prev - 1,
);
} else if (e.key === "Enter") {
e.preventDefault();
if (filteredCommands[selectedIndex]) {
filteredCommands[selectedIndex].action();
if (filteredCommands[selectedIndex].id !== "copy-email") {
setIsOpen(false);
}
}
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-52 flex items-start justify-center pt-24 bg-slate-950/90 backdrop-blur-md p-4 animate-in fade-in duration-150"
onClick={() => setIsOpen(false)}
>
<div
className="w-full max-w-xl bg-slate-900/95 border border-slate-800 rounded-2xl shadow-2xl shadow-black/80 overflow-hidden border-t-slate-700/60 font-sans"
onClick={(e) => e.stopPropagation()}
>
{/* Search Input Bar */}
<div className="flex items-center px-4 py-3.5 border-b border-slate-800/80 bg-slate-950/50">
<svg
className="w-4 h-4 text-blue-400 mr-3 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
placeholder="Type a command or action..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleInputKeyDown}
className="w-full bg-transparent text-slate-100 placeholder-slate-500 outline-none text-sm tracking-wide font-mono"
/>
<kbd className="text-[10px] font-mono bg-slate-800 text-slate-400 px-2 py-0.5 rounded border border-slate-700">
ESC
</kbd>
</div>
{/* Command List */}
<div className="max-h-100 overflow-y-auto p-2 space-y-1">
{filteredCommands.length === 0 ? (
<p className="p-4 text-center text-xs font-mono text-slate-500">
No matching commands found.
</p>
) : (
filteredCommands.map((cmd, index) => {
const isSelected = index === selectedIndex;
return (
<button
key={cmd.id}
onClick={() => {
cmd.action();
if (cmd.id !== "copy-email") setIsOpen(false);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl text-xs transition-all duration-150 font-mono cursor-pointer ${
isSelected
? "bg-slate-800/80 text-white border-l-2 border-blue-500 pl-4"
: "text-slate-400 hover:text-slate-200"
}`}
>
<div className="flex items-center space-x-3">
<span className="text-sm">{cmd.icon}</span>
<span className="font-medium tracking-wide">
{cmd.label}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
{cmd.category}
</span>
{isSelected && (
<kbd className="text-[10px] text-slate-300 bg-slate-800 px-1.5 py-0.5 rounded border border-slate-700">
</kbd>
)}
</div>
</button>
);
})
)}
</div>
{/* Footer Hint */}
<div className="px-4 py-2.5 border-t border-slate-800/60 bg-slate-950/60 flex items-center justify-between text-[11px] font-mono text-slate-500">
<span>
Use{" "}
<kbd className="text-slate-400 bg-slate-900 px-1 rounded border border-slate-800">
</kbd>{" "}
<kbd className="text-slate-400 bg-slate-900 px-1 rounded border border-slate-800">
</kbd>{" "}
to navigate
</span>
<span>
<kbd className="text-slate-400 bg-slate-900 px-1 rounded border border-slate-800">
K
</kbd>{" "}
/{" "}
<kbd className="text-slate-400 bg-slate-900 px-1 rounded border border-slate-800">
Ctrl+K
</kbd>
</span>
</div>
</div>
</div>
);
}

View file

@ -3,9 +3,13 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
/**
* Main navigation header with desktop/mobile views and Command Palette trigger
*/
export default function Header() { export default function Header() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// Smooth scroll navigation targets
const navLinks = [ const navLinks = [
{ label: "About me", href: "#aboutme" }, { label: "About me", href: "#aboutme" },
{ label: "My Skills", href: "#myskills" }, { label: "My Skills", href: "#myskills" },
@ -13,9 +17,15 @@ export default function Header() {
{ label: "Contact me", href: "#contactme" }, { label: "Contact me", href: "#contactme" },
]; ];
// Dispatches global custom event to open Cmd+K palette
const triggerCmdK = () => {
window.dispatchEvent(new CustomEvent("toggle-cmd-k"));
};
return ( return (
<header className="fixed top-0 left-0 w-full bg-slate-950/50 backdrop-blur-md z-50 px-4 sm:px-4 py-4"> <header className="fixed top-0 left-0 w-full bg-slate-950/60 backdrop-blur-md z-50 px-4 py-4 border-b border-slate-900/50">
<div className="max-w-5xl mx-auto flex justify-between items-center"> <div className="max-w-5xl mx-auto flex justify-between items-center">
{/* Brand Logo */}
<Link <Link
href="/" href="/"
className="text-xl font-bold text-white hover:text-blue-400 transition-colors tracking-wider z-50" className="text-xl font-bold text-white hover:text-blue-400 transition-colors tracking-wider z-50"
@ -24,8 +34,9 @@ export default function Header() {
ANDRÉ KEMPF<span className="text-blue-500">.</span> ANDRÉ KEMPF<span className="text-blue-500">.</span>
</Link> </Link>
{/* Desktop navigation */} {/* Right Area: Desktop Nav & Cmd+K Trigger */}
<nav className="hidden md:flex gap-8 font-medium text-white"> <div className="hidden md:flex items-center gap-8">
<nav className="flex gap-8 font-medium text-white text-sm">
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link <Link
key={link.href} key={link.href}
@ -37,13 +48,41 @@ export default function Header() {
))} ))}
</nav> </nav>
{/* Mobile burger button */} {/* Integrated Cmd+K Pill */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={triggerCmdK}
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-slate-900/80 border border-slate-800 text-slate-400 hover:text-white hover:border-slate-700 hover:bg-slate-900 transition-all cursor-pointer font-mono text-xs shadow-xs"
title="Open command palette (⌘K)"
>
<svg
className="w-3.5 h-3.5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span className="text-[11px]">Search</span>
<kbd className="bg-slate-800 text-[10px] text-slate-300 px-1.5 py-0.5 rounded border border-slate-700/80 font-mono">
K
</kbd>
</button>
</div>
{/* Mobile Hamburger Toggle */}
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="md:hidden flex flex-col justify-center items-center w-8 h-8 space-y-1.5 z-50 focus:outline-none cursor-pointer group" className="md:hidden flex flex-col justify-center items-center w-8 h-8 space-y-1.5 z-50 focus:outline-none cursor-pointer group"
aria-label="Toggle Menu" aria-label="Toggle Menu"
> >
{/* Animated burger icon lines */}
<span <span
className={`block w-6 h-0.5 bg-slate-100 transition-all duration-300 group-hover:bg-blue-400 ${ className={`block w-6 h-0.5 bg-slate-100 transition-all duration-300 group-hover:bg-blue-400 ${
isOpen ? "rotate-45 translate-y-2" : "" isOpen ? "rotate-45 translate-y-2" : ""
@ -61,8 +100,9 @@ export default function Header() {
/> />
</button> </button>
{/* Mobile fullscreen overlay */} {/* Mobile Fullscreen Navigation Overlay */}
<div <div
onClick={() => setIsOpen(false)}
className={`fixed inset-0 w-full h-screen bg-slate-950/98 flex flex-col items-center justify-center gap-8 text-2xl font-semibold transition-all duration-300 md:hidden ${ className={`fixed inset-0 w-full h-screen bg-slate-950/98 flex flex-col items-center justify-center gap-8 text-2xl font-semibold transition-all duration-300 md:hidden ${
isOpen isOpen
? "opacity-100 pointer-events-auto" ? "opacity-100 pointer-events-auto"
@ -73,12 +113,22 @@ export default function Header() {
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => setIsOpen(false)}
className="hover:text-blue-400 text-white transition-colors" className="hover:text-blue-400 text-white transition-colors"
> >
{link.label} {link.label}
</Link> </Link>
))} ))}
{/* Mobile Cmd+K Trigger inside Overlay */}
<button
onClick={triggerCmdK}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-300 text-sm font-mono mt-4"
>
<span>Open Commands</span>
<kbd className="bg-slate-800 text-xs px-2 py-0.5 rounded border border-slate-700">
K
</kbd>
</button>
</div> </div>
</div> </div>
</header> </header>

View file

@ -0,0 +1,88 @@
/**
* Command item structure for the Cmd+K palette
*/
export type CommandItem = {
id: string;
label: string;
category: "Quick Actions" | "System & Info" | "Social / Links";
icon?: string;
action: () => void;
};
/**
* Returns the array of registered palette actions, including dynamic label state and event triggers.
*/
export const getCommands = (
copied: boolean,
setCopied: (value: boolean) => void,
): CommandItem[] => [
{
id: "copy-email",
label: copied ? "Email Copied! ✓" : "Copy Email (dev@andre-kempf.com)",
category: "Quick Actions",
icon: copied ? "✓" : "✉️",
action: () => {
navigator.clipboard.writeText("dev@andre-kempf.com");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
},
},
{
id: "download-cv",
label: "Download Resume / CV (PDF)",
category: "Quick Actions",
icon: "📄",
action: () => {
const link = document.createElement("a");
link.href =
"https://andre-kempf.com/assets/downloads/Andre_Kempf_Lebenslauf_2025.pdf";
link.download = "Andre_Kempf_Lebenslauf.pdf";
link.click();
},
},
{
id: "terminal",
label: "Open Analytics Terminal",
category: "Quick Actions",
icon: "⚡",
action: () => {
window.dispatchEvent(new CustomEvent("open-analytics"));
},
},
{
id: "codeberg",
label: "View Codeberg Profile",
category: "Social / Links",
icon: "🏔️",
action: () => {
window.open("https://codeberg.org/Chneemann", "_blank");
},
},
{
id: "github",
label: "View GitHub (Legacy) Profile",
category: "Social / Links",
icon: "🐙",
action: () => {
window.open("https://github.com/andre-kempf", "_blank");
},
},
{
id: "linkedin",
label: "View LinkedIn Profile",
category: "Social / Links",
icon: "💼",
action: () => {
window.open("https://linkedin.com/in/andre-kempf", "_blank");
},
},
{
id: "source-code",
label: "View Site Source Code (Codeberg)",
category: "Social / Links",
icon: "💻",
action: () => {
window.open("https://codeberg.org/Chneemann/portfolio", "_blank");
},
},
];