feat: About Me

This commit is contained in:
2026-08-19 18:05:20 +08:00
commit 214acfd33c
37 changed files with 6118 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
export function Hero() {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
// 细线缓慢绘制
gsap.fromTo(
".hero-line",
{ strokeDashoffset: 600 },
{
strokeDashoffset: 0,
duration: 2.4,
ease: "power2.inOut",
stagger: 0.3
}
);
gsap.fromTo(
".hero-shape",
{ opacity: 0, scale: 0.96 },
{
opacity: 1,
scale: 1,
duration: 1.8,
ease: "power2.out",
stagger: 0.2
}
);
// 缓慢呼吸浮动
gsap.to(".hero-float", {
y: -10,
duration: 4,
ease: "sine.inOut",
yoyo: true,
repeat: -1
});
gsap.to(".hero-breathe", {
opacity: 0.35,
duration: 3.2,
ease: "sine.inOut",
yoyo: true,
repeat: -1
});
gsap.fromTo(
".hero-hint",
{ opacity: 0 },
{ opacity: 1, duration: 1.5, delay: 2, ease: "power2.out" }
);
}, rootRef);
return () => ctx.revert();
}, []);
return (
<section
ref={rootRef}
aria-label="首屏"
className="relative flex h-svh w-full items-center justify-center overflow-hidden"
>
<div className="hero-float relative -translate-x-4">
<svg
width="320"
height="320"
viewBox="0 0 320 320"
fill="none"
aria-hidden="true"
className="h-56 w-56 md:h-80 md:w-80"
>
{/* 外圈细线圆 */}
<circle
className="hero-line"
cx="160"
cy="160"
r="120"
stroke="oklch(0.98 0.004 90 / 0.16)"
strokeWidth="1"
strokeDasharray="600"
strokeDashoffset="600"
/>
{/* 内部偏移圆 */}
<circle
className="hero-line"
cx="176"
cy="144"
r="72"
stroke="oklch(0.62 0.05 155 / 0.45)"
strokeWidth="1"
strokeDasharray="600"
strokeDashoffset="600"
/>
{/* 对角细线 */}
<line
className="hero-line"
x1="48"
y1="272"
x2="272"
y2="48"
stroke="oklch(0.98 0.004 90 / 0.12)"
strokeWidth="1"
strokeDasharray="600"
strokeDashoffset="600"
/>
{/* 小方形节点 */}
<rect
className="hero-shape"
x="156"
y="156"
width="8"
height="8"
fill="oklch(0.72 0.07 75 / 0.7)"
/>
<circle
className="hero-shape hero-breathe"
cx="232"
cy="88"
r="3"
fill="oklch(0.62 0.05 155 / 0.8)"
/>
<circle
className="hero-shape hero-breathe"
cx="88"
cy="232"
r="2"
fill="oklch(0.98 0.004 90 / 0.4)"
/>
</svg>
</div>
<div className="hero-hint absolute bottom-10 left-1/2 -translate-x-1/2 opacity-0">
<span className="text-[11px] uppercase tracking-[0.3em] text-muted-foreground/60">
Scroll
</span>
</div>
</section>
);
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import { SectionLabel } from "./section-label";
import { ZoomableImage } from "./zoomable-image";
import { ProjectIntroDialog } from "./project-intro-dialog";
import {
personalProjects,
type PersonalProject
} from "@/lib/personal-projects";
const MD_COLUMNS_QUERY = "(min-width: 768px)";
/** 是否处于双列断点(与 md:columns-2 对齐) */
const useMdColumns = () => {
const [isMd, setIsMd] = useState(false);
useEffect(() => {
const media = window.matchMedia(MD_COLUMNS_QUERY);
const onChange = () => setIsMd(media.matches);
onChange();
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
return isMd;
};
/** 双列按 mdOrder 排;没填则回退到原数组位置 */
const sortByMdOrder = (items: PersonalProject[]) => {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aOrder = a.item.mdOrder ?? a.index;
const bOrder = b.item.mdOrder ?? b.index;
return aOrder - bOrder || a.index - b.index;
})
.map(({ item }) => item);
};
const ProjectLink = ({
href,
variant,
children
}: {
href: string;
variant: "primary" | "outline";
children: React.ReactNode;
}) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={
variant === "primary"
? "inline-flex items-center gap-2 rounded-full bg-foreground px-5 py-2.5 text-xs font-medium uppercase tracking-[0.15em] text-background transition-opacity duration-300 hover:opacity-80"
: "inline-flex items-center gap-2 rounded-full border border-border px-5 py-2.5 text-xs font-medium uppercase tracking-[0.15em] text-foreground transition-colors duration-300 hover:border-foreground/50 hover:bg-secondary"
}
>
{children}
<span aria-hidden="true" className="text-[10px]">
</span>
</a>
);
};
const PersonalCard = ({ project }: { project: PersonalProject }) => {
return (
<article className="mb-6 break-inside-avoid overflow-hidden rounded-lg border border-border bg-card transition-all duration-300 hover:border-foreground/20 hover:shadow-[0_0_20px_-6px_oklch(0.62_0.05_155_/_0.25)]">
<div className="overflow-hidden border-b border-border">
<ZoomableImage
src={project.image}
alt={`${project.title} 预览`}
width={960}
height={600}
/>
</div>
<div className="flex flex-col gap-4 p-5">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-medium text-foreground text-balance">
{project.title}
</h3>
{(project.meta?.company || project.meta?.period) && (
<p className="text-xs tracking-wide text-muted-foreground/80">
{[project.meta.company, project.meta.period]
.filter(Boolean)
.join(" · ")}
</p>
)}
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{project.description}
</p>
{(project.live ||
project.gitea ||
project.showIntro ||
project.meta?.status) && (
<div className="flex flex-wrap items-center gap-3">
{project.live && (
<ProjectLink href={project.live} variant="primary">
Live
</ProjectLink>
)}
{project.gitea && (
<ProjectLink href={project.gitea} variant="outline">
Gitea
</ProjectLink>
)}
{project.showIntro && (
<ProjectIntroDialog
title={project.title}
intro={project.intro}
/>
)}
{project.meta?.status && (
<span className="inline-flex items-center rounded-full border border-dashed border-border px-5 py-2.5 text-xs uppercase tracking-[0.15em] text-muted-foreground">
{project.meta.status}
</span>
)}
</div>
)}
</div>
</article>
);
};
/** 实习项目下方的个人作品:PC 两列瀑布流 */
export const PersonalProjects = ({
items = personalProjects
}: {
items?: PersonalProject[];
}) => {
const isMdColumns = useMdColumns();
const visibleItems = isMdColumns ? sortByMdOrder(items) : items;
return (
<section aria-label="项目经历" className="py-32 md:py-24">
<div className="mx-auto max-w-6xl px-6">
<SectionLabel>Project</SectionLabel>
<div className="columns-1 md:columns-2 md:gap-6">
{visibleItems.map((project) => (
<PersonalCard key={project.id} project={project} />
))}
</div>
</div>
</section>
);
};
+83
View File
@@ -0,0 +1,83 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
type ProjectIntroDialogProps = {
title: string;
intro?: string;
};
const markdownComponents: Components = {
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
img: ({ src, alt }) => (
<img
src={typeof src === "string" ? src : undefined}
alt={alt ?? ""}
className="my-3 h-auto w-full rounded-lg border border-border"
/>
),
table: ({ children }) => (
<div className="mb-3 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border bg-muted/50 px-2 py-1.5 text-left font-medium text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1.5">{children}</td>
)
};
/** 可选的项目介绍:shadcn Button 打开 Dialog,正文按 Markdown 渲染 */
export const ProjectIntroDialog = ({
title,
intro = ""
}: ProjectIntroDialogProps) => {
const body = intro.trim() || "暂无介绍";
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="outline"
className="h-auto rounded-full border-border px-5 py-2.5 text-xs font-medium uppercase tracking-[0.15em]"
/>
}
>
Intro
</DialogTrigger>
<DialogContent className="max-w-2xl p-6">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">{title} </DialogDescription>
<div
className="mt-3 max-h-[70vh] overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden text-sm leading-relaxed text-muted-foreground [&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-4 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-medium [&_h1]:text-foreground [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:text-foreground [&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-sm [&_h3]:font-medium [&_h3]:text-foreground [&_ol]:mb-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5 [&_p]:mb-3 [&_p]:last:mb-0 [&_pre]:mb-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_strong]:font-medium [&_strong]:text-foreground [&_ul]:mb-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{body}
</ReactMarkdown>
</div>
</DialogContent>
</Dialog>
);
};
+7
View File
@@ -0,0 +1,7 @@
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h2 className="mb-12 text-[11px] font-medium uppercase tracking-[0.3em] text-muted-foreground">
{children}
</h2>
);
}
+9
View File
@@ -0,0 +1,9 @@
export function SiteFooter() {
return (
<footer className="mx-auto max-w-6xl px-6 pb-16 pt-8">
<p className="text-center text-xs tracking-wide text-muted-foreground/50">
{"2026"}
</p>
</footer>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SectionLabel } from "./section-label";
gsap.registerPlugin(ScrollTrigger);
const groups: { name: string; items: string[] }[] = [
{
name: "语言",
items: ["TypeScript", "JavaScript", "Python"]
},
{
name: "前端",
items: ["Vue 3", "Nuxt", "Pinia", "Tailwind CSS", "Sass / Less"]
},
{
name: "UI 与可视化",
items: ["Naive UI", "Ant Design", "shadcn/ui", "ECharts"]
},
{
name: "后端",
items: ["Node.js", "Koa", "FastAPI"]
},
{
name: "工程化",
items: ["Vite", "pnpm", "Git"]
},
{
name: "设计",
items: ["Figma", "Pixso", "蓝湖"]
},
{
name: "AI 编程",
items: ["Cursor", "Copilot", "Claude Code", "Codex", "V0"]
},
{
name: "AI 模型",
items: ["GPT", "Claude", "Grok", "DeepSeek", "MiMo"]
}
];
export function TechStack() {
const rootRef = useRef<HTMLElement>(null);
useEffect(() => {
const ctx = gsap.context(() => {
gsap.fromTo(
".tech-item",
{ opacity: 0, y: 24 },
{
opacity: 1,
y: 0,
duration: 0.7,
ease: "power2.out",
stagger: 0.06,
scrollTrigger: {
trigger: rootRef.current,
start: "top 75%"
}
}
);
}, rootRef);
return () => ctx.revert();
}, []);
return (
<section
ref={rootRef}
aria-label="技术栈"
className="mx-auto max-w-6xl px-6 py-32 md:py-40"
>
<SectionLabel>Tech Stack</SectionLabel>
<div className="grid grid-cols-1 gap-12 md:grid-cols-2 md:gap-x-16">
{groups.map((group) => (
<div key={group.name} className="tech-item">
<p className="mb-5 text-xs uppercase tracking-[0.2em] text-muted-foreground/60">
{group.name}
</p>
<ul className="flex flex-wrap gap-3">
{group.items.map((item) => (
<li key={item} className="tech-item">
<span className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-sm text-secondary-foreground transition-all duration-300 hover:scale-[1.04] hover:border-foreground/20 hover:shadow-[0_0_20px_-6px_oklch(0.62_0.05_155_/_0.25)]">
<span
aria-hidden="true"
className="h-1 w-1 rounded-full bg-sage/70"
/>
{item}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const Dialog = (props: DialogPrimitive.Root.Props) => {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
};
const DialogTrigger = (props: DialogPrimitive.Trigger.Props) => {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
};
const DialogPortal = (props: DialogPrimitive.Portal.Props) => {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
};
const DialogClose = (props: DialogPrimitive.Close.Props) => {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
};
const DialogOverlay = ({
className,
...props
}: DialogPrimitive.Backdrop.Props) => {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0",
className
)}
{...props}
/>
);
};
const DialogContent = ({
className,
children,
showCloseButton = true,
overlayClassName,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
overlayClassName?: string;
}) => {
return (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 outline-none duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute top-2 right-2"
/>
}
>
<XIcon />
<span className="sr-only"></span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
};
const DialogTitle = ({
className,
...props
}: DialogPrimitive.Title.Props) => {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base font-medium", className)}
{...props}
/>
);
};
const DialogDescription = ({
className,
...props
}: DialogPrimitive.Description.Props) => {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
};
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogClose,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription
};
+65
View File
@@ -0,0 +1,65 @@
"use client";
import Image from "next/image";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
type ZoomableImageProps = {
src: string;
alt: string;
width: number;
height: number;
className?: string;
};
/** 点击图片放大预览,再点任意区域关闭 */
export const ZoomableImage = ({
src,
alt,
width,
height,
className
}: ZoomableImageProps) => {
const imageSrc = src || "/placeholder.svg";
const previewLabel = alt || "图片预览";
return (
<Dialog>
<DialogTrigger
className={`block w-full cursor-zoom-in border-0 bg-transparent p-0 ${className ?? ""}`}
aria-label={alt ? `放大查看:${alt}` : "放大查看图片"}
>
<Image
src={imageSrc}
alt={alt}
width={width}
height={height}
className="h-auto w-full"
/>
</DialogTrigger>
<DialogContent
showCloseButton={false}
overlayClassName="cursor-zoom-out bg-background/90 backdrop-blur-sm"
className="w-full max-w-none border-0 bg-transparent p-0 px-2 shadow-none ring-0 md:w-auto md:px-0"
>
<DialogTitle className="sr-only">{previewLabel}</DialogTitle>
{/* 点击预览图本身也会关闭 */}
<DialogClose className="block w-full cursor-zoom-out border-0 bg-transparent p-0 md:w-[80vw]">
<Image
src={imageSrc}
alt={alt}
width={width * 2}
height={height * 2}
className="h-auto max-h-svh w-full object-contain"
/>
</DialogClose>
</DialogContent>
</Dialog>
);
};