项目页面
前言
Firefly 主题移植了一个专业的项目展示(Projects)页面,用于展示您参与或开发的项目作品。这个页面可以帮助访客了解您的技术能力和项目经验。
本文档详细介绍如何将日记页面功能移植到 Firefly-Hyde 主题中。
在开始之前,请确保你已经按照文件结构创建好对应的目录和文件。
📁 文件结构
public/
├── js/
│ └── filter-tabs-handler.js ✨ 新增筛选标签处理器
src/
├── components/
│ ├── atoms/
│ │ └── FilterTabs.astro ✨ 新增 - 筛选标签组件
│ │ └── index.ts ✨ 新增 - 组件导出
│ └── features/
│ └── page-header/
│ ├── index.ts ✨ 新增 - 组件导出
│ ├── types.ts ✨ 新增 - 类型定义
│ └── PageHeader.astro ✨ 新增 - 页面头部组件
│ └── projects/
│ ├── index.ts ✨ 新增 - 组件导出
│ ├── types.ts ✨ 新增 - 类型定义
│ └── ProjectCard.astro ✨ 新增 - 项目卡片组件
├── config/
│ └── siteConfig.ts ✏️ 修改 - 添加页面开关配置
│ └── navBarConfig.ts ✏️ 修改 - 导航栏配置
├── constants/
│ └── link-presets.ts ✏️ 修改 - 添加导航链接
├── data/
│ └── projects.ts ✨ 新增 - 项目数据管理
├── i18n/
│ ├── i18nKey.ts ✏️ 修改 - 添加翻译键
│ └── languages/
│ ├── en.ts ✏️ 修改 - 英文翻译
│ ├── zh_CN.ts ✏️ 修改 - 中文翻译
│ ├── zh_TW.ts ✏️ 修改 - 繁体翻译
│ ├── ja.ts ✏️ 修改 - 日文翻译
│ └── ru.ts ✏️ 修改 - 俄文翻译
├── pages/
│ └── projects.astro ✨ 新增 - 项目页面
├── types/
│ └── config.ts ✏️ 修改 - 添加类型定义创建选标签处理器
文件路径:public/js/filter-tabs-handler.js
// Shared filter handler for FilterTabs atom component
// Works with Swup page transitions
// FilterTabs renders data-filter-attr and data-filter-value on each button
// Cards/entries should have a matching data attribute (e.g. data-category, data-type)
(function () {
function initFilterTabs(reset) {
var containers = document.querySelectorAll(".filter-tabs");
containers.forEach(function (container) {
if (!reset && container.dataset.initialized) return;
container.dataset.initialized = "true";
var tabs = container.querySelectorAll(".filter-tabs-item");
var filterAttr = tabs[0] ? tabs[0].dataset.filterAttr : null;
if (!filterAttr) return;
var dataSelector = "[data-" + filterAttr + "]";
var parent = container.closest(".card-base") || document;
var items = parent.querySelectorAll(dataSelector);
var noResults = parent.querySelector("#no-results");
if (items.length === 0) return;
tabs.forEach(function (tab) {
tab.addEventListener("click", function () {
tabs.forEach(function (t) {
t.classList.remove("active");
});
tab.classList.add("active");
var activeValue = tab.dataset.filterValue || "all";
var visibleCount = 0;
items.forEach(function (item) {
var itemValue = item.dataset[filterAttr];
var match =
activeValue === "all" || (itemValue && itemValue.split(",").indexOf(activeValue) !== -1);
if (match) {
item.classList.remove("filtered-out");
visibleCount++;
} else {
item.classList.add("filtered-out");
}
});
if (noResults) {
noResults.classList.toggle("hidden", visibleCount > 0);
}
});
});
});
}
// Expose for dynamic tab rebuild (e.g. Memos API fetch)
window.__initFilterTabs = function () {
initFilterTabs(true);
};
function onInit() {
if (document.querySelector(".filter-tabs")) {
initFilterTabs(false);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onInit);
} else {
onInit();
}
document.addEventListener("astro:page-load", onInit);
})();创建筛选标签组件
文件路径:src/components/atoms/FilterTabs.astro和src/components/atoms/index.ts
---
import { Icon } from "astro-icon/components";
interface FilterTab {
value: string;
label: string;
icon?: string;
count?: number;
}
interface Props {
tabs: FilterTab[];
dataAttr: string;
activeValue?: string;
class?: string;
}
const {
tabs,
dataAttr,
activeValue = "all",
class: className = "",
} = Astro.props;
---
<div class:list={["filter-tabs", className]}>
{
tabs.map((tab) => (
<button
class:list={[
"filter-tabs-item",
{ active: tab.value === activeValue },
]}
data-filter-value={tab.value}
data-filter-attr={dataAttr}
>
{tab.icon && <Icon name={tab.icon} class="text-base w-4 h-4" />}
<span>{tab.label}</span>
{tab.count !== undefined && (
<span class="filter-tabs-count">({tab.count})</span>
)}
</button>
))
}
</div>
<style>
.filter-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-tabs-item {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border: 1px solid var(--line-divider);
border-radius: var(--radius-large);
background: var(--btn-regular-bg);
color: var(--btn-content);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.filter-tabs-item iconify-icon {
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.filter-tabs-item:hover:not(.active) {
background: var(--btn-regular-bg-hover);
border-color: var(--primary);
}
.filter-tabs-item:hover:not(.active) iconify-icon {
opacity: 1;
}
.filter-tabs-item.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.filter-tabs-item.active iconify-icon {
opacity: 1;
}
.filter-tabs-count {
opacity: 0.6;
font-size: 0.8rem;
}
@media (max-width: 768px) {
.filter-tabs {
gap: 0.375rem;
}
.filter-tabs-item {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.filter-tabs-count {
display: none;
}
}
</style>export { default as FilterTabs } from "./FilterTabs.astro";创建页面头部组件
文件路径:
src/components/features/diary/index.ts组件导出文件路径:
src/components/features/diary/types.ts类型定义文件路径:
src/components/features/diary/PageHeader.astro页面头部组件
export { default as PageHeader } from "./PageHeader.astro";
export * from "./types";export interface PageHeaderProps {
title: string;
subtitle?: string;
class?: string;
}---
import type { PageHeaderProps } from "./types";
const {
title,
subtitle,
class: className = "",
} = Astro.props as PageHeaderProps;
---
<div class={`flex flex-col items-start justify-center mb-8 ${className}`}>
<h1
class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4"
>
{title}
</h1>
{
subtitle && (
<p class="text-lg text-black/60 dark:text-white/60">{subtitle}</p>
)
}
</div>创建项目卡片组件
文件路径:
src/components/features/diary/index.ts组件导出文件路径:
src/components/features/diary/types.ts类型定义文件路径:
src/components/features/diary/ProjectCard.astro项目卡片组件
export { default as ProjectCard } from "./ProjectCard.astro";
export * from "./types";export interface Project {
id: string;
title: string;
description: string;
image?: string;
category: string;
techStack: string[];
status: "completed" | "in-progress" | "planned";
demoUrl?: string;
sourceUrl?: string;
liveDemo?: string;
sourceCode?: string;
visitUrl?: string;
startDate: string;
endDate?: string;
featured?: boolean;
tags?: string[];
showImage?: boolean;
}
export interface ProjectCardProps {
project: Project;
size?: "small" | "medium" | "large";
showImage?: boolean;
maxTechStack?: number;
}---
import { Icon } from "astro-icon/components";
import I18nKey from "../../../i18n/i18nKey";
import { i18n } from "../../../i18n/translation";
import type { ProjectCardProps } from "./types";
const { project, maxTechStack = 4 } = Astro.props as ProjectCardProps;
const hasImage = !!project.image;
const showImageArea = project.showImage !== false && hasImage;
const hasVisitUrl = !!project.visitUrl;
const hasSourceCode = !!project.sourceCode;
const getStatusText = (status: string) => {
switch (status) {
case "completed":
return i18n(I18nKey.projectsCompleted);
case "in-progress":
return i18n(I18nKey.projectsInProgress);
case "planned":
return i18n(I18nKey.projectsPlanned);
default:
return status;
}
};
---
<div
class="project-card group relative rounded-xl border border-black/10 dark:border-white/10 overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
data-category={project.category}
>
{
showImageArea && (
<div class="aspect-video overflow-hidden relative bg-gradient-to-br from-[var(--primary)]/5 to-[var(--primary)]/10">
{hasImage ? (
<img
src={project.image!}
alt={project.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
) : (
<div class="w-full h-full flex items-center justify-center">
<span class="text-4xl font-bold text-[var(--primary)]/15 select-none tracking-wide">
{project.title}
</span>
</div>
)}
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{project.featured && (
<div class="absolute top-3 right-3">
<Icon
name="material-symbols:star-rounded"
class="text-[var(--primary)] text-xl drop-shadow-sm"
style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2));"
/>
</div>
)}
</div>
)
}
<div class="p-5">
<div class="flex items-center justify-between mb-3">
<h3
class="text-lg font-bold text-black/90 dark:text-white/90 truncate group-hover:text-[var(--primary)] transition-colors duration-200"
>
{project.title}
</h3>
<span
class="shrink-0 ml-3 px-2 py-0.5 text-xs rounded-md bg-[var(--primary)]/10 text-[var(--primary)] font-medium"
>
{getStatusText(project.status)}
</span>
</div>
<p
class="text-sm text-black/60 dark:text-white/60 mb-4 line-clamp-2 min-h-[2.5rem]"
>
{project.description}
</p>
{
project.techStack && project.techStack.length > 0 && (
<div class="flex flex-wrap gap-2 mb-4">
{project.techStack.slice(0, maxTechStack).map((tech) => (
<span class="px-2 py-1 text-xs rounded-md bg-[var(--primary)]/10 text-[var(--primary)] font-medium">
{tech}
</span>
))}
{project.techStack.length > maxTechStack && (
<span class="px-2 py-1 text-xs rounded-md bg-[var(--btn-regular-bg)] text-black/50 dark:text-white/50 font-medium">
+{project.techStack.length - maxTechStack}
</span>
)}
</div>
)
}
{
(hasVisitUrl || hasSourceCode) && (
<div class="flex gap-2">
{hasVisitUrl && (
<a
href={project.visitUrl!}
target="_blank"
rel="noopener noreferrer"
class="btn-regular flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium"
>
<Icon
name="material-symbols:open-in-new"
class="text-base w-4 h-4"
/>
{i18n(I18nKey.projectsVisit)}
</a>
)}
{hasSourceCode && (
<a
href={project.sourceCode!}
target="_blank"
rel="noopener noreferrer"
class="btn-regular flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium"
style={hasVisitUrl ? "" : "flex: 1;"}
>
<Icon name="mdi:github" class="text-base w-4 h-4" />
{!hasVisitUrl && "GitHub"}
</a>
)}
</div>
)
}
</div>
<div
class="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
>
</div>
</div>
<style>
.project-card {
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.project-card.filtered-out {
display: none;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.project-card:nth-child(1) {
animation-delay: 0.05s;
}
.project-card:nth-child(2) {
animation-delay: 0.1s;
}
.project-card:nth-child(3) {
animation-delay: 0.15s;
}
.project-card:nth-child(4) {
animation-delay: 0.2s;
}
.project-card:nth-child(5) {
animation-delay: 0.25s;
}
.project-card:nth-child(6) {
animation-delay: 0.3s;
}
.project-card:nth-child(7) {
animation-delay: 0.35s;
}
.project-card:nth-child(8) {
animation-delay: 0.4s;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>页面开关导航栏配置
在
SiteConfig类型中添加页面开关配置,文件路径:src/config/siteConfig.ts在
navBarConfig类型中更新导航栏配置,文件路径:src/config/navBarConfig.ts
// 页面开关配置
pages: {
// ... 其他配置
// 项目页面开关
projects: true,
},// 我的及其子菜单
links.push({
name: "我的",
url: "/my/",
icon: "material-symbols:person",
children: [
// 根据配置决定是否添加项目,在siteConfig关闭pages.projects时导航栏不显示项目
...(siteConfig.pages.projects ? [LinkPreset.Projects] : []),
],
});更新类型配置链接
在
types类型的config中添加类型定义,文件路径:src/types/config.ts在
constants类型的link-presets添加到导航链接,文件路径:src/constants/link-presets.ts
export type SiteConfig = {
// 页面开关配置
pages: {
projects?: boolean; // 项目页面开关
};
}
export enum LinkPreset {
Projects = 10, // ✨ 新增
}export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
[LinkPreset.Projects]: {
name: i18n(I18nKey.projects),
url: "/projects/",
icon: "material-symbols:work",
},
}项目数据管理
文件路径:src/data/projects.ts
// Project data configuration file
// Used to manage data for the project display page
export interface Project {
id: string;
title: string;
description: string;
image: string;
category: "web" | "mobile" | "desktop" | "other";
techStack: string[];
status: "completed" | "in-progress" | "planned";
liveDemo?: string;
sourceCode?: string;
visitUrl?: string;
startDate: string;
endDate?: string;
featured?: boolean;
tags?: string[];
showImage?: boolean;
}
export const projectsData: Project[] = [
{
id: "Firefly", //(必填) 项目的唯一标识符,通常是字符串格式的名称。用于内部引用和过滤。
title: "Firefly", //(必填) 项目的名称,显示在项目列表中。
// 项目的详细描述,可以多行文本。
description:
"Firefly 是一款基于 Astro 框架和 Fuwari 模板开发的清新美观且现代化个人博客主题模板,专为技术爱好者和内容创作者设计。该主题融合了现代 Web 技术栈,提供了丰富的功能模块和高度可定制的界面,让您能够轻松打造出专业且美观的个人博客网站。",
// (必填) 项目展示图片的路径,通常放在 public/images/projects/ 目录下。
image: "https://docs-firefly.cuteleaf.cn/images/1.webp",
//"web" | "mobile" | "desktop" | "other": (必填) 项目的类型分类,用于筛选。
category: "web",
// (必填) 项目使用的技术栈数组,如 ["React", "Node.js"]。
techStack: ["Astro", "TypeScript", "Tailwind CSS", "Svelte"],
// "completed" | "in-progress" | "planned": (必填) 项目的当前状态,用于筛选。
status: "completed",
// (可选) 项目源代码仓库的URL地址,通常是GitHub链接。
sourceCode: "https://github.com/CuteLeaf/Firefly",
// (可选) 项目访问链接,可以是演示链接或项目主页。
visitUrl: "https://firefly.cuteleaf.cn/",
// (必填) 项目开始日期,格式为 "YYYY-MM-DD"。
startDate: "2025-10-111",
// (可选) 项目结束日期,格式为 "YYYY-MM-DD"。对于进行中的项目,此字段可省略。
endDate: "2024-06-01",
// (可选) 是否为特色项目,特色项目会优先展示。
featured: true,
// (可选) 项目标签数组,用于更细致的分类。
tags: ["Blog", "Theme", "Open Source"],
},
{
id: "vue-pure-admin",
title: "vue-pure-admin",
description:
"一款开源免费且开箱即用的中后台管理系统模块版本。完全采用ECMAScript模块(ESM)规范来编写和组织代码,使用了最新的Vue3、 Vite、Element-Plus、TypeScript、Pinia、Tailwindcss等主流技术开发",
image: "https://camo.githubusercontent.com/56acae4e405b42111d2ba2b56a85c3d07a93fe0d2e4865960da81df74386af3c/68747470733a2f2f7869616f7869616e3532312e6769746875622e696f2f68797065726c696e6b2f696d672f7675652d707572652d61646d696e2f312e6a7067",
category: "mobile",
techStack: ["Vue3", "Vite", "Element-Plus++", "TypeScript","Pinia","Tailwindcss"],
status: "in-progress",
sourceCode: "https://github.com/pure-admin/vue-pure-admin",
visitUrl: "https://pure-admin.github.io/vue-pure-admin/#/login",
startDate: "2024-03-01",
featured: true,
tags: ["Android", "Root", "Kernel"],
},
{
id: "file-transfer-go",
title: "文件快传 - P2P文件传输工具",
description:
"Go/React开发的端到端webrtc的文件传输/文字传输/桌面共享,安全,隐私,数据不经过服务器。",
image: "https://raw.githubusercontent.com/MatrixSeven/file-transfer-go/refs/heads/main/img.png",
category: "desktop",
techStack: ["Go", "React "],
status: "completed",
sourceCode: "https://github.com/MatrixSeven/file-transfer-go",
visitUrl: "https://transfer.52python.cn/",
startDate: "2026-02-01",
endDate: "2026-02-28",
tags: ["Android", "Tool", "Desktop"],
showImage: true,
},
];
// Get project statistics
export const getProjectStats = () => {
const total = projectsData.length;
const completed = projectsData.filter(
(p) => p.status === "completed",
).length;
const inProgress = projectsData.filter(
(p) => p.status === "in-progress",
).length;
const planned = projectsData.filter((p) => p.status === "planned").length;
return {
total,
byStatus: {
completed,
inProgress,
planned,
},
};
};
// Get projects by category
export const getProjectsByCategory = (category?: string) => {
if (!category || category === "all") {
return projectsData;
}
return projectsData.filter((p) => p.category === category);
};
// Get featured projects
export const getFeaturedProjects = () => {
return projectsData.filter((p) => p.featured);
};
// Get all tech stacks
export const getAllTechStack = () => {
const techSet = new Set<string>();
projectsData.forEach((project) => {
project.techStack.forEach((tech) => {
techSet.add(tech);
});
});
return Array.from(techSet).sort();
};添加国际化翻译
添加翻译键,文件路径:
src/i18n/i18nKey.ts添加英文翻译,文件路径:
src/i18n/languages/en.ts添加中文翻译,文件路径:
src/i18n/languages/zh_CN.ts添加繁体中文翻译,文件路径:
src/i18n/languages/zh_TW.ts添加日文翻译,文件路径:
src/i18n/languages/ja.ts添加俄文翻译,文件路径:
src/i18n/languages/ru.ts
export enum I18nKey {
// 项目展示页面
projects = "projects",
projectsSubtitle = "projectsSubtitle",
projectsAll = "projectsAll",
projectsWeb = "projectsWeb",
projectsMobile = "projectsMobile",
projectsDesktop = "projectsDesktop",
projectsOther = "projectsOther",
projectTechStack = "projectTechStack",
projectLiveDemo = "projectLiveDemo",
projectSourceCode = "projectSourceCode",
projectDescription = "projectDescription",
projectStatus = "projectStatus",
projectStatusCompleted = "projectStatusCompleted",
projectStatusInProgress = "projectStatusInProgress",
projectStatusPlanned = "projectStatusPlanned",
projectsTotal = "projectsTotal",
projectsCompleted = "projectsCompleted",
projectsInProgress = "projectsInProgress",
projectsTechStack = "projectsTechStack",
projectsFeatured = "projectsFeatured",
projectsPlanned = "projectsPlanned",
projectsDemo = "projectsDemo",
projectsSource = "projectsSource",
projectsVisit = "projectsVisit",
projectsGitHub = "projectsGitHub",
} // Projects Page
[Key.projects]: "Projects",
[Key.projectsSubtitle]: "My development project portfolio",
[Key.projectsAll]: "All",
[Key.projectsWeb]: "Web Applications",
[Key.projectsMobile]: "Mobile Applications",
[Key.projectsDesktop]: "Desktop Applications",
[Key.projectsOther]: "Other",
[Key.projectTechStack]: "Tech Stack",
[Key.projectLiveDemo]: "Live Demo",
[Key.projectSourceCode]: "Source Code",
[Key.projectDescription]: "Project Description",
[Key.projectStatus]: "Status",
[Key.projectStatusCompleted]: "Completed",
[Key.projectStatusInProgress]: "In Progress",
[Key.projectStatusPlanned]: "Planned",
[Key.projectsTotal]: "Total Projects",
[Key.projectsCompleted]: "Completed",
[Key.projectsInProgress]: "In Progress",
[Key.projectsTechStack]: "Tech Stack Statistics",
[Key.projectsFeatured]: "Featured Projects",
[Key.projectsPlanned]: "Planned",
[Key.projectsDemo]: "Live Demo",
[Key.projectsSource]: "Source Code",
[Key.projectsVisit]: "Visit Project",
[Key.projectsGitHub]: "GitHub", // 项目展示页面
[Key.projects]: "项目展示",
[Key.projectsSubtitle]: "我的开发项目作品集",
[Key.projectsAll]: "全部",
[Key.projectsWeb]: "网页应用",
[Key.projectsMobile]: "移动应用",
[Key.projectsDesktop]: "桌面应用",
[Key.projectsOther]: "其他",
[Key.projectTechStack]: "技术栈",
[Key.projectLiveDemo]: "在线演示",
[Key.projectSourceCode]: "源代码",
[Key.projectDescription]: "项目描述",
[Key.projectStatus]: "项目状态",
[Key.projectStatusCompleted]: "已完成",
[Key.projectStatusInProgress]: "进行中",
[Key.projectStatusPlanned]: "计划中",
[Key.projectsTotal]: "项目总数",
[Key.projectsCompleted]: "已完成",
[Key.projectsInProgress]: "进行中",
[Key.projectsTechStack]: "技术栈统计",
[Key.projectsFeatured]: "精选项目",
[Key.projectsPlanned]: "计划中",
[Key.projectsDemo]: "在线演示",
[Key.projectsSource]: "源代码",
[Key.projectsVisit]: "前往",
[Key.projectsGitHub]: "GitHub", // 專案展示頁面
[Key.projects]: "專案展示",
[Key.projectsSubtitle]: "我的開發專案作品集",
[Key.projectsAll]: "全部",
[Key.projectsWeb]: "網頁應用",
[Key.projectsMobile]: "移動應用",
[Key.projectsDesktop]: "桌面應用",
[Key.projectsOther]: "其他",
[Key.projectTechStack]: "技術堆疊",
[Key.projectLiveDemo]: "線上展示",
[Key.projectSourceCode]: "原始碼",
[Key.projectDescription]: "專案描述",
[Key.projectStatus]: "專案狀態",
[Key.projectStatusCompleted]: "已完成",
[Key.projectStatusInProgress]: "進行中",
[Key.projectStatusPlanned]: "計劃中",
[Key.projectsTotal]: "專案總數",
[Key.projectsCompleted]: "已完成",
[Key.projectsInProgress]: "進行中",
[Key.projectsTechStack]: "技術堆疊統計",
[Key.projectsFeatured]: "精選專案",
[Key.projectsPlanned]: "計劃中",
[Key.projectsDemo]: "線上展示",
[Key.projectsSource]: "原始碼",
[Key.projectsVisit]: "前往專案",
[Key.projectsGitHub]: "GitHub", // プロジェクトページ
[Key.projects]: "プロジェクト",
[Key.projectsSubtitle]: "開発プロジェクトのポートフォリオ",
[Key.projectsAll]: "すべて",
[Key.projectsWeb]: "ウェブアプリ",
[Key.projectsMobile]: "モバイルアプリ",
[Key.projectsDesktop]: "デスクトップアプリ",
[Key.projectsOther]: "その他",
[Key.projectTechStack]: "技術スタック",
[Key.projectLiveDemo]: "ライブデモ",
[Key.projectSourceCode]: "ソースコード",
[Key.projectDescription]: "プロジェクトの説明",
[Key.projectStatus]: "ステータス",
[Key.projectStatusCompleted]: "完了",
[Key.projectStatusInProgress]: "進行中",
[Key.projectStatusPlanned]: "計画中",
[Key.projectsTotal]: "プロジェクトの合計",
[Key.projectsCompleted]: "完了",
[Key.projectsInProgress]: "進行中",
[Key.projectsTechStack]: "技術スタック",
[Key.projectsFeatured]: "注目のプロジェクト",
[Key.projectsPlanned]: "計画中",
[Key.projectsDemo]: "ライブデモ",
[Key.projectsSource]: "ソースコード",
[Key.projectsVisit]: "プロジェクトを開く",
[Key.projectsGitHub]: "GitHub", // Страница проектов
[Key.projects]: "Проекты",
[Key.projectsSubtitle]: "Мой портфель проектов",
[Key.projectsAll]: "Все",
[Key.projectsWeb]: "Веб-приложения",
[Key.projectsMobile]: "Мобильные приложения",
[Key.projectsDesktop]: "Десктопные приложения",
[Key.projectsOther]: "Другое",
[Key.projectTechStack]: "Технологический стек",
[Key.projectLiveDemo]: "Онлайн демо",
[Key.projectSourceCode]: "Исходный код",
[Key.projectDescription]: "Описание проекта",
[Key.projectStatus]: "Статус проекта",
[Key.projectStatusCompleted]: "Завершено",
[Key.projectStatusInProgress]: "В процессе",
[Key.projectStatusPlanned]: "Запланировано",
[Key.projectsTotal]: "Всего проектов",
[Key.projectsCompleted]: "Завершено",
[Key.projectsInProgress]: "В процессе",
[Key.projectsTechStack]: "Статистика технологий",
[Key.projectsFeatured]: "Избранные проекты",
[Key.projectsPlanned]: "Запланировано",
[Key.projectsDemo]: "Онлайн демо",
[Key.projectsSource]: "Исходный код",
[Key.projectsVisit]: "Посетить",
[Key.projectsGitHub]: "GitHub",新增项目页面组件
文件路径:src/pages/projects.astro
---
import { FilterTabs } from "@components/atoms";
import { PageHeader } from "@components/features/page-header";
import { ProjectCard } from "@components/features/projects";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { Icon } from "astro-icon/components";
import { siteConfig } from "../config";
import { UNCATEGORIZED } from "../constants/constants";
import { projectsData } from "../data/projects";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
if (!siteConfig.pages.projects) {
return Astro.redirect("/404/");
}
const categories = [
...new Set(projectsData.map((project) => project.category)),
];
const getCategoryText = (category: string) => {
switch (category) {
case "web":
return i18n(I18nKey.projectsWeb);
case "mobile":
return i18n(I18nKey.projectsMobile);
case "desktop":
return i18n(I18nKey.projectsDesktop);
case "other":
return i18n(I18nKey.projectsOther);
case UNCATEGORIZED:
return i18n(I18nKey.uncategorized);
default:
return category;
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case "web":
return "material-symbols:language";
case "mobile":
return "material-symbols:smartphone";
case "desktop":
return "material-symbols:desktop-windows";
case "other":
return "material-symbols:widgets";
default:
return "material-symbols:folder";
}
};
const filterTabs = [
{
value: "all",
label: i18n(I18nKey.friendsFilterAll),
icon: "material-symbols:apps",
count: projectsData.length,
},
...categories.map((category) => ({
value: category,
label: getCategoryText(category),
icon: getCategoryIcon(category),
count: projectsData.filter((p) => p.category === category).length,
})),
];
const title = i18n(I18nKey.projects);
const subtitle = i18n(I18nKey.projectsSubtitle);
---
<MainGridLayout title={title} description={subtitle}>
<script>
import { initIconLoader } from "../utils/icon-loader";
initIconLoader();
</script>
<script is:inline src="/js/filter-tabs-handler.js"></script>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32"
>
<div class="card-base z-10 px-6 sm:px-9 py-6 relative w-full">
<PageHeader title={title} subtitle={subtitle} />
<div class="mb-8">
<FilterTabs tabs={filterTabs} dataAttr="category" />
</div>
<div
id="projects-grid"
class="grid grid-cols-1 md:grid-cols-2 gap-6 items-start"
>
{
projectsData.map((project) => (
<ProjectCard project={project} maxTechStack={4} />
))
}
</div>
<div id="no-results" class="hidden text-center py-16">
<Icon
name="material-symbols:search-off-rounded"
class="text-6xl text-black/15 dark:text-white/15 mb-4"
/>
<p class="text-black/40 dark:text-white/40 text-lg">
No matching projects
</p>
</div>
</div>
</div>
</MainGridLayout>配置教程
有关项目页面配置教程,请参考 项目页面配置教程
