feat: About Me
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user