feat: add Cmd+K command palette with actions and keyboard navigation
This commit is contained in:
parent
afb1622563
commit
1d15fb28c3
4 changed files with 357 additions and 18 deletions
|
|
@ -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 (
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<body>
|
||||
<CommandPalette />
|
||||
<Background />
|
||||
<Header />
|
||||
<main className="grow">{children}</main>
|
||||
|
|
|
|||
192
src/components/CommandPalette.tsx
Normal file
192
src/components/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<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">
|
||||
{/* Brand Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<nav className="hidden md:flex gap-8 font-medium text-white">
|
||||
{/* Right Area: Desktop Nav & Cmd+K Trigger */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<nav className="flex gap-8 font-medium text-white text-sm">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
|
|
@ -37,13 +48,41 @@ export default function Header() {
|
|||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile burger button */}
|
||||
{/* Integrated Cmd+K Pill */}
|
||||
<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"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
{/* Animated burger icon lines */}
|
||||
<span
|
||||
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" : ""
|
||||
|
|
@ -61,8 +100,9 @@ export default function Header() {
|
|||
/>
|
||||
</button>
|
||||
|
||||
{/* Mobile fullscreen overlay */}
|
||||
{/* Mobile Fullscreen Navigation Overlay */}
|
||||
<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 ${
|
||||
isOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
|
|
@ -73,12 +113,22 @@ export default function Header() {
|
|||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="hover:text-blue-400 text-white transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</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>
|
||||
</header>
|
||||
|
|
|
|||
88
src/lib/paletteCommands.ts
Normal file
88
src/lib/paletteCommands.ts
Normal 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");
|
||||
},
|
||||
},
|
||||
];
|
||||
Loading…
Reference in a new issue