"use client";
import * as React from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Check, Copy, FolderOpen, Hash, Mail, SunMoon } from "lucide-react";
import { cn } from "@/lib/utils";
import { defaultPortfolioData, type PortfolioData, type Post, type Project } from "./data";
import { About } from "./sections/about";
import { CommandPalette, type PaletteItem } from "./sections/command-palette";
import { Contact, type ContactMessage } from "./sections/contact";
import { Footer } from "./sections/footer";
import { Intro } from "./sections/intro";
import { Navbar } from "./sections/navbar";
import { ProjectModal } from "./sections/project-modal";
import { Services } from "./sections/services";
import { Skills } from "./sections/skills";
import { Testimonials } from "./sections/testimonials";
import { scrollToId, useCopy } from "./sections/ui";
import { Work } from "./sections/work";
import { Writing } from "./sections/writing";
export type { PortfolioData, Project, Post, ContactMessage };
export { defaultPortfolioData };
export interface PortfolioTemplateProps {
/** Override any top-level content group (person, work, about…). Missing groups use the demo content. */
data?: Partial<PortfolioData>;
/** Called when the contact form is submitted. Return a promise to keep the sending state. */
onContact?: (message: ContactMessage) => void | Promise<void>;
/** Called when a writing entry is clicked (route to your blog here). */
onReadPost?: (post: Post) => void;
/** Adds a "Toggle theme" command to the ⌘K palette. Defaults to toggling the `dark` class on <html>. */
onToggleTheme?: (() => void) | false;
className?: string;
}
export function PortfolioTemplate({ data, onContact, onReadPost, onToggleTheme, className }: PortfolioTemplateProps) {
const d = React.useMemo(() => ({ ...defaultPortfolioData, ...data }), [data]);
const reduce = useReducedMotion();
const [palette, setPalette] = React.useState(false);
const [project, setProject] = React.useState<Project | null>(null);
const [interest, setInterest] = React.useState(d.services.items[0]?.title ?? "Something else");
const [modKey, setModKey] = React.useState("Ctrl ");
const [toast, setToast] = React.useState<string | null>(null);
const { copy } = useCopy();
React.useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) setModKey("⌘");
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setPalette((p) => !p);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2200);
return () => window.clearTimeout(t);
}, [toast]);
const closePalette = React.useCallback(() => setPalette(false), []);
const closeProject = React.useCallback(() => setProject(null), []);
const items = React.useMemo<PaletteItem[]>(() => {
const nav: PaletteItem[] = [{ id: "intro", label: "Home" }, ...d.nav].map((n) => ({
id: `nav-${n.id}`,
group: "Navigate",
label: n.label,
hint: `#${n.id}`,
icon: <Hash className="size-4" />,
run: () => scrollToId(n.id, reduce),
}));
const projects: PaletteItem[] = d.work.projects.map((p) => ({
id: `p-${p.id}`,
group: "Projects",
label: p.title,
hint: `${p.category} · ${p.year}`,
keywords: `${p.client} ${p.role.join(" ")}`,
icon: <FolderOpen className="size-4" />,
run: () => setProject(p),
}));
const actions: PaletteItem[] = [
{
id: "copy",
group: "Actions",
label: "Copy email address",
hint: d.person.email,
icon: <Copy className="size-4" />,
run: () => {
copy(d.person.email);
setToast("Email copied to clipboard");
},
},
{ id: "mail", group: "Actions", label: `Email ${d.person.name.split(" ")[0]}`, icon: <Mail className="size-4" />, run: () => (window.location.href = `mailto:${d.person.email}`) },
{ id: "start", group: "Actions", label: "Start a project", icon: <ArrowRight className="size-4" />, run: () => scrollToId("contact", reduce) },
];
if (onToggleTheme !== false) {
actions.push({
id: "theme",
group: "Actions",
label: "Toggle light / dark",
keywords: "theme dark light mode",
icon: <SunMoon className="size-4" />,
run: () => (onToggleTheme ? onToggleTheme() : document.documentElement.classList.toggle("dark")),
});
}
return [...nav, ...projects, ...actions];
}, [d, reduce, copy, onToggleTheme]);
const chooseService = (title: string) => {
setInterest(title);
scrollToId("contact", reduce);
};
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative w-full bg-background text-foreground antialiased selection:bg-lime-300 selection:text-neutral-950", className)}>
<Navbar name={d.person.name} links={d.nav} available={d.person.available} onOpenPalette={() => setPalette(true)} modKey={modKey} />
<main>
<Intro person={d.person} intro={d.intro} />
<Work work={d.work} onOpen={setProject} />
<About about={d.about} initials={d.person.initials} />
<Skills skills={d.skills} />
<Services services={d.services} onChoose={chooseService} />
<Writing writing={d.writing} onRead={onReadPost} />
<Testimonials testimonials={d.testimonials} />
<Contact
contact={d.contact}
email={d.person.email}
services={d.services.items.map((s) => s.title)}
interest={interest}
onInterest={setInterest}
onSend={onContact}
/>
</main>
<Footer person={d.person} nav={d.nav} note={d.footer.note} onOpenPalette={() => setPalette(true)} modKey={modKey} />
<CommandPalette open={palette} onClose={closePalette} items={items} />
<ProjectModal project={project} projects={d.work.projects} onClose={closeProject} onNavigate={setProject} />
<AnimatePresence>
{toast && (
<motion.div
role="status"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10 }}
className="fixed bottom-6 left-1/2 z-[90] flex -translate-x-1/2 items-center gap-2 rounded-full bg-foreground px-4 py-2.5 text-sm text-background shadow-xl"
>
<Check className="size-4 text-lime-400" /> {toast}
</motion.div>
)}
</AnimatePresence>
</div>
</MotionConfig>
);
}