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");
+ },
+ },
+];