Fazekit

Code

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