feat(components): extract and reuse ProjectCard for cleaner project showcases

- Created `ProjectCard` component to unify and streamline project card presentation.
- Updated `index.astro`, `projects.astro`, and `zh/projects.astro` to use `ProjectCard` for improved maintainability and consistency.
- Simplified layout and removed duplicate styles across pages.
This commit is contained in:
zguiyang
2026-03-14 11:41:45 +08:00
parent 32954cf69a
commit 2474d51e1b
4 changed files with 132 additions and 161 deletions

View File

@@ -0,0 +1,123 @@
---
import { useTranslations } from "@/i18n/utils";
import type { Lang } from "@/types/i18n";
interface Props {
project: {
id: string;
title: string;
icon: string;
type: string;
status: string;
impact?: string;
description: string[];
tech: string[];
link: string;
featured?: boolean;
image?: {
bg: string;
hover?: string;
text?: string;
};
links?: {
github?: string;
demo?: string;
};
};
lang: Lang;
showStatus?: boolean;
}
const { project, lang, showStatus = false } = Astro.props;
const t = useTranslations(lang);
const statusClassMap = {
building: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30",
completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
archived: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
} as const;
const statusColor = statusClassMap[project.status as keyof typeof statusClassMap] ?? statusClassMap.completed;
---
<article
data-project-card
data-type={project.type}
class="group flex flex-col overflow-hidden rounded-3xl border border-border/50 bg-card/50 backdrop-blur-sm transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-primary/5"
>
{/* 顶部封面图区域 */}
<div class="relative aspect-[16/10] overflow-hidden">
<div class={`absolute inset-0 bg-gradient-to-br ${project.image?.bg || 'from-primary/20 to-purple-500/20'} transition-transform duration-700 group-hover:scale-110`}></div>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-6xl transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3 select-none">{project.icon}</span>
</div>
<div class="absolute top-4 right-4 flex flex-col items-end gap-2">
<span class="rounded-full bg-background/80 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-foreground backdrop-blur-md border border-white/10 shadow-sm">
{t(`project.type.${project.type}`)}
</span>
{project.featured && !showStatus && (
<span class="rounded-full bg-primary/20 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-primary backdrop-blur-md border border-primary/30 shadow-sm">
{t("project.featured")}
</span>
)}
</div>
</div>
{/* 内容区域 */}
<div class="flex flex-1 flex-col p-6 lg:p-7">
<div class="flex-1 space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col gap-1.5">
{showStatus && (
<div class="flex items-center gap-2">
<span class={`inline-flex rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider shadow-sm ${statusColor}`}>
{t(`project.status.${project.status}`)}
</span>
</div>
)}
<h3 class="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">{project.title}</h3>
</div>
{project.featured && showStatus && (
<span class="shrink-0 rounded-full bg-primary/15 border border-primary/30 px-2 py-0.5 text-[10px] font-bold text-primary">
{t("project.featured")}
</span>
)}
</div>
<p class="line-clamp-2 text-[13px] leading-relaxed text-muted-foreground/90">
{project.impact || project.description[0]}
</p>
<div class="flex flex-wrap gap-1.5 pt-1">
{project.tech.slice(0, 4).map((tech) => (
<span class="rounded-md bg-primary/5 px-2 py-0.5 text-[10px] font-medium text-primary/80 border border-primary/10">
{tech}
</span>
))}
</div>
</div>
{/* 操作按钮 */}
<div class="mt-6 flex items-center justify-between gap-4 pt-4 border-t border-border/40">
<a
href={project.link}
class="inline-flex h-9 items-center justify-center rounded-full bg-primary px-5 text-[11px] font-bold text-primary-foreground transition-all hover:translate-y-[-1px] hover:shadow-lg hover:shadow-primary/25 active:translate-y-0"
>
{t("project.visit") || t("home.featured.ctaPrimary")}
</a>
{project.links?.github && (
<a
href={project.links.github}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/50 text-muted-foreground transition-all hover:bg-muted hover:text-foreground hover:border-primary/30"
aria-label="GitHub"
>
<svg class="h-4.5 w-4.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 4.125 9.642 9.807 11.53 0.6.111 0.799-0.26 0.799-0.577 0-0.285-0.011-1.04-0.017-2.042-3.338 0.727-4.042-1.61-4.042-1.61-0.546-1.387-1.333-1.756-1.333-1.756-1.089-0.745 0.083-0.729 0.083-0.729 1.205 0.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492 0.997 0.107-0.775 0.418-1.305 0.762-1.604-2.665-0.305-5.467-1.334-5.467-5.931 0-1.311 0.469-2.381 1.236-3.221-0.124-0.303-0.535-1.524 0.117-3.176 0 0 1.008-0.322 3.301 1.23 0.96-0.267 1.98-0.399 3-0.405 1.02 0.006 2.04 0.138 3 0.405 2.28-1.552 3.285-1.23 3.285-1.23 0.654 1.653 0.242 2.874 0.118 3.176 0.77 0.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921 0.43 0.372 0.823 1.102 0.823 2.222 0 1.606-0.014 2.898-0.014 3.293 0 0.319 0.192 0.694 0.801 0.576 5.687-1.889 9.812-6.228 9.812-11.53 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
)}
</div>
</div>
</article>

View File

@@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader"; import GlassHeader from "@/components/GlassHeader";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Container from "@/components/ui/Container.astro"; import Container from "@/components/ui/Container.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { useTranslations } from "@/i18n/utils"; import { useTranslations } from "@/i18n/utils";
import type { Lang } from "@/types/i18n"; import type { Lang } from "@/types/i18n";
import { defaultLang } from "@/i18n/ui"; import { defaultLang } from "@/i18n/ui";
@@ -206,52 +207,9 @@ const keyFacts = [
<h2 class="text-4xl font-bold tracking-tight sm:text-5xl">{t("home.featured.title")}</h2> <h2 class="text-4xl font-bold tracking-tight sm:text-5xl">{t("home.featured.title")}</h2>
</div> </div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{featuredProjects.map((project) => ( {featuredProjects.map((project) => (
<article class="group flex flex-col overflow-hidden rounded-3xl border border-border/50 bg-card/50 backdrop-blur-sm transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-primary/5"> <ProjectCard project={project} lang={lang} />
{/* 顶部封面图区域 */}
<div class="relative aspect-video overflow-hidden">
<div class={`absolute inset-0 bg-gradient-to-br ${project.image?.bg || 'from-primary/20 to-purple-500/20'} transition-transform duration-700 group-hover:scale-110`}></div>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-6xl transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3">{project.icon}</span>
</div>
<div class="absolute top-4 right-4">
<span class="rounded-full bg-background/80 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-foreground backdrop-blur-md">
{project.type}
</span>
</div>
</div>
{/* 内容区域 */}
<div class="flex flex-1 flex-col p-6 lg:p-8">
<div class="flex-1 space-y-4">
<h3 class="text-2xl font-bold tracking-tight">{project.title}</h3>
<p class="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{project.impact}
</p>
<div class="flex flex-wrap gap-2">
{project.tech.map((tech) => (
<span class="rounded-md bg-primary/5 px-2 py-1 text-[10px] font-medium text-primary/80">
{tech}
</span>
))}
</div>
</div>
{/* 操作按钮 */}
<div class="mt-8 flex items-center justify-between gap-4">
<a href={`${prefix}/projects`} class="inline-flex h-10 items-center justify-center rounded-full bg-primary px-6 text-xs font-bold text-primary-foreground transition-all hover:bg-primary/90">
{t("home.featured.ctaPrimary")}
</a>
{project.links?.github && (
<a href={project.links.github} target="_blank" rel="noopener noreferrer" class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background/50 text-muted-foreground transition-all hover:bg-muted hover:text-foreground">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 4.125 9.642 9.807 11.53 0.6.111 0.799-0.26 0.799-0.577 0-0.285-0.011-1.04-0.017-2.042-3.338 0.727-4.042-1.61-4.042-1.61-0.546-1.387-1.333-1.756-1.333-1.756-1.089-0.745 0.083-0.729 0.083-0.729 1.205 0.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492 0.997 0.107-0.775 0.418-1.305 0.762-1.604-2.665-0.305-5.467-1.334-5.467-5.931 0-1.311 0.469-2.381 1.236-3.221-0.124-0.303-0.535-1.524 0.117-3.176 0 0 1.008-0.322 3.301 1.23 0.96-0.267 1.98-0.399 3-0.405 1.02 0.006 2.04 0.138 3 0.405 2.28-1.552 3.285-1.23 3.285-1.23 0.654 1.653 0.242 2.874 0.118 3.176 0.77 0.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921 0.43 0.372 0.823 1.102 0.823 2.222 0 1.606-0.014 2.898-0.014 3.293 0 0.319 0.192 0.694 0.801 0.576 5.687-1.889 9.812-6.228 9.812-11.53 0-6.627-5.373-12-12-12z"/></svg>
</a>
)}
</div>
</div>
</article>
))} ))}
</div> </div>
</Container> </Container>

View File

@@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader"; import GlassHeader from "@/components/GlassHeader";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Container from "@/components/ui/Container.astro"; import Container from "@/components/ui/Container.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { useTranslations } from "@/i18n/utils"; import { useTranslations } from "@/i18n/utils";
import type { Lang } from "@/types/i18n"; import type { Lang } from "@/types/i18n";
import { defaultLang } from "@/i18n/ui"; import { defaultLang } from "@/i18n/ui";
@@ -22,12 +23,6 @@ const filterOptions = [
{ key: "client", label: t("project.type.client") }, { key: "client", label: t("project.type.client") },
{ key: "experiment", label: t("project.type.experiment") }, { key: "experiment", label: t("project.type.experiment") },
]; ];
const statusClassMap = {
building: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30",
completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
archived: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
} as const;
--- ---
<Layout title={pageTitle}> <Layout title={pageTitle}>
@@ -71,59 +66,9 @@ const statusClassMap = {
))} ))}
</div> </div>
<div class="grid gap-5 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{currentProjects.map((project) => ( {currentProjects.map((project) => (
<article <ProjectCard project={project} lang={lang} showStatus={true} />
data-project-card
data-type={project.type}
class="page-surface p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
>
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<span>{project.icon}</span>
<span>{project.title}</span>
</h2>
<p class="mt-1 text-sm text-muted-foreground">{t(`project.type.${project.type}`)}</p>
</div>
<div class="flex flex-col items-end gap-2">
{project.featured && (
<span class="rounded-full border border-primary/40 bg-primary/15 px-2.5 py-1 text-xs font-medium text-primary">
{t("project.featured")}
</span>
)}
<span class={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusClassMap[project.status as keyof typeof statusClassMap] ?? statusClassMap.completed}`}>
{t(`project.status.${project.status}`)}
</span>
</div>
</div>
<div class="space-y-1 text-sm text-muted-foreground">
{project.description.slice(0, 2).map((desc) => (
<p>{desc}</p>
))}
</div>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 4).map((tech) => (
<span class="rounded-md border border-border/80 bg-muted/60 px-2 py-1 text-xs text-muted-foreground">{tech}</span>
))}
</div>
<div class="mt-5 flex items-center gap-4 text-sm font-medium">
<a href={project.link} class="text-primary hover:text-primary/80 transition-colors inline-flex items-center gap-1">
{t('project.visit')}
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
{project.links?.github && (
<a href={project.links.github} target="_blank" rel="noopener noreferrer" class="text-muted-foreground hover:text-foreground transition-colors">
GitHub
</a>
)}
</div>
</article>
))} ))}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import Layout from "@/layouts/Layout.astro";
import GlassHeader from "@/components/GlassHeader"; import GlassHeader from "@/components/GlassHeader";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import Container from "@/components/ui/Container.astro"; import Container from "@/components/ui/Container.astro";
import ProjectCard from "@/components/ProjectCard.astro";
import { useTranslations } from "@/i18n/utils"; import { useTranslations } from "@/i18n/utils";
import type { Lang } from "@/types/i18n"; import type { Lang } from "@/types/i18n";
import { defaultLang } from "@/i18n/ui"; import { defaultLang } from "@/i18n/ui";
@@ -22,12 +23,6 @@ const filterOptions = [
{ key: "client", label: t("project.type.client") }, { key: "client", label: t("project.type.client") },
{ key: "experiment", label: t("project.type.experiment") }, { key: "experiment", label: t("project.type.experiment") },
]; ];
const statusClassMap = {
building: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30",
completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
archived: "bg-slate-500/15 text-slate-700 dark:text-slate-300 border-slate-500/30",
} as const;
--- ---
<Layout title={pageTitle}> <Layout title={pageTitle}>
@@ -71,59 +66,9 @@ const statusClassMap = {
))} ))}
</div> </div>
<div class="grid gap-5 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{currentProjects.map((project) => ( {currentProjects.map((project) => (
<article <ProjectCard project={project} lang={lang} showStatus={true} />
data-project-card
data-type={project.type}
class="page-surface p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
>
<div class="mb-4 flex items-start justify-between gap-3">
<div>
<h2 class="text-xl font-semibold flex items-center gap-2">
<span>{project.icon}</span>
<span>{project.title}</span>
</h2>
<p class="mt-1 text-sm text-muted-foreground">{t(`project.type.${project.type}`)}</p>
</div>
<div class="flex flex-col items-end gap-2">
{project.featured && (
<span class="rounded-full border border-primary/40 bg-primary/15 px-2.5 py-1 text-xs font-medium text-primary">
{t("project.featured")}
</span>
)}
<span class={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusClassMap[project.status as keyof typeof statusClassMap] ?? statusClassMap.completed}`}>
{t(`project.status.${project.status}`)}
</span>
</div>
</div>
<div class="space-y-1 text-sm text-muted-foreground">
{project.description.slice(0, 2).map((desc) => (
<p>{desc}</p>
))}
</div>
<div class="mt-4 flex flex-wrap gap-2">
{project.tech.slice(0, 4).map((tech) => (
<span class="rounded-md border border-border/80 bg-muted/60 px-2 py-1 text-xs text-muted-foreground">{tech}</span>
))}
</div>
<div class="mt-5 flex items-center gap-4 text-sm font-medium">
<a href={project.link} class="text-primary hover:text-primary/80 transition-colors inline-flex items-center gap-1">
{t('project.visit')}
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
{project.links?.github && (
<a href={project.links.github} target="_blank" rel="noopener noreferrer" class="text-muted-foreground hover:text-foreground transition-colors">
GitHub
</a>
)}
</div>
</article>
))} ))}
</div> </div>
</div> </div>