From 1d15fb28c3611e69bd72bf5c2c15337f7cddbe1c Mon Sep 17 00:00:00 2001 From: Chneemann Date: Tue, 28 Jul 2026 08:26:28 +0200 Subject: [PATCH] feat: add Cmd+K command palette with actions and keyboard navigation --- src/app/layout.tsx | 9 ++ src/components/CommandPalette.tsx | 192 ++++++++++++++++++++++++++++++ src/components/Header.tsx | 86 ++++++++++--- src/lib/paletteCommands.ts | 88 ++++++++++++++ 4 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/lib/paletteCommands.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0e88cf9..cd8d8b0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,9 @@ import "./globals.css"; import Header from "../components/Header"; import Footer from "../components/Footer"; import Background from "../components/Background"; +import CommandPalette from "../components/CommandPalette"; +// Load Google Fonts as CSS custom variables const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -19,6 +21,9 @@ const geistMono = Geist_Mono({ preload: false, }); +/** + * Global page metadata and branding icons + */ export const metadata: Metadata = { title: "André Kempf | Portfolio", 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({ children, }: Readonly<{ @@ -37,6 +45,7 @@ export default function RootLayout({ return ( +
{children}
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..168ce26 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -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(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 ( +
setIsOpen(false)} + > +
e.stopPropagation()} + > + {/* Search Input Bar */} +
+ + + + { + 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" + /> + + ESC + +
+ + {/* Command List */} +
+ {filteredCommands.length === 0 ? ( +

+ No matching commands found. +

+ ) : ( + filteredCommands.map((cmd, index) => { + const isSelected = index === selectedIndex; + return ( + + ); + }) + )} +
+ + {/* Footer Hint */} +
+ + Use{" "} + + ↑ + {" "} + + ↓ + {" "} + to navigate + + + + ⌘K + {" "} + /{" "} + + Ctrl+K + + +
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 834f69a..c3b65a9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -3,9 +3,13 @@ import { useState } from "react"; import Link from "next/link"; +/** + * Main navigation header with desktop/mobile views and Command Palette trigger + */ export default function Header() { const [isOpen, setIsOpen] = useState(false); + // Smooth scroll navigation targets const navLinks = [ { label: "About me", href: "#aboutme" }, { label: "My Skills", href: "#myskills" }, @@ -13,9 +17,15 @@ export default function Header() { { label: "Contact me", href: "#contactme" }, ]; + // Dispatches global custom event to open Cmd+K palette + const triggerCmdK = () => { + window.dispatchEvent(new CustomEvent("toggle-cmd-k")); + }; + return ( -
+
+ {/* Brand Logo */} . - {/* Desktop navigation */} - + {/* Right Area: Desktop Nav & Cmd+K Trigger */} +
+ - {/* Mobile burger button */} + {/* Integrated Cmd+K Pill */} + +
+ + {/* Mobile Hamburger Toggle */} - {/* Mobile fullscreen overlay */} + {/* Mobile Fullscreen Navigation Overlay */}
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 ${ isOpen ? "opacity-100 pointer-events-auto" @@ -73,12 +113,22 @@ export default function Header() { setIsOpen(false)} className="hover:text-blue-400 text-white transition-colors" > {link.label} ))} + + {/* Mobile Cmd+K Trigger inside Overlay */} +
diff --git a/src/lib/paletteCommands.ts b/src/lib/paletteCommands.ts new file mode 100644 index 0000000..d49929f --- /dev/null +++ b/src/lib/paletteCommands.ts @@ -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"); + }, + }, +];