151 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
};
|