Files
AboutMe/components/personal-projects.tsx
T
2026-08-19 18:05:20 +08:00

151 lines
4.8 KiB
TypeScript

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