Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/components/pages/projects/ProjectCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script lang="ts">
import type { ProjectItem } from "@/types/projectsConfig";

interface Props {
item: ProjectItem;
}

const { item }: Props = $props();

const tags = $derived(item.tags ?? []);
const visibleTags = $derived(tags.slice(0, 4));
const hiddenTagCount = $derived(Math.max(tags.length - visibleTags.length, 0));

const hasDetail = $derived(!!(item.content || (item.features && item.features.length > 0) || (item.screenshots && item.screenshots.length > 0)));
const href = $derived(item.link || (hasDetail ? `/projects/${item.id}/` : undefined));
const isExternal = $derived(item.external && !!item.link);
const clickable = $derived(!!href);

function handleLoad(e: Event) {
const img = e.currentTarget as HTMLImageElement;
img.style.opacity = "1";
const ph = img.parentElement?.querySelector(".lqip-placeholder");
if (ph) ph.classList.add("loaded");
}
</script>

{#if clickable}
<a
{href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer nofollow" : undefined}
class="group relative flex flex-col overflow-hidden rounded-xl transition-all duration-300 hover:shadow-lg hover:scale-[1.02] bg-(--card-bg) border border-(--line-divider) h-full"
>
<!-- 封面 -->
<div class="aspect-[16/10] relative overflow-hidden bg-(--btn-regular-bg)">
{#if item.cover}
<div class="lqip-placeholder absolute inset-0 pointer-events-none" style="background: var(--muted)" aria-hidden="true"></div>
<img
src={item.cover}
alt={item.title}
class="w-full h-full object-cover opacity-0 transition-all duration-500 ease-out group-hover:scale-105"
loading="lazy"
decoding="async"
onload={handleLoad}
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--btn-content)">
<span class="text-4xl">🚀</span>
</div>
{/if}
</div>

<!-- 内容 -->
<div class="flex flex-col flex-1 p-4">
<h3 class="font-bold text-base text-neutral-900 dark:text-neutral-100 line-clamp-1 mb-1 group-hover:text-(--primary) transition-colors">
{item.title || "Untitled"}
</h3>
{#if item.description}
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-2 mb-3 flex-1">
{item.description}
</p>
{:else}
<div class="flex-1"></div>
{/if}

<div class="flex items-center justify-between mt-auto pt-2">
{#if visibleTags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each visibleTags as tag}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--btn-regular-bg) text-(--btn-content)">{tag}</span>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--btn-regular-bg) text-(--btn-content)">+{hiddenTagCount}</span>
{/if}
</div>
{:else}
<span></span>
{/if}
{#if item.date}
<span class="text-xs text-neutral-500 dark:text-neutral-500 whitespace-nowrap ml-2">{item.date}</span>
{/if}
</div>
</div>
</a>
{:else}
<div
class="group relative flex flex-col overflow-hidden rounded-xl bg-(--card-bg) border border-(--line-divider) h-full cursor-default"
>
<!-- 封面 -->
<div class="aspect-[16/10] relative overflow-hidden bg-(--btn-regular-bg)">
{#if item.cover}
<div class="lqip-placeholder absolute inset-0 pointer-events-none" style="background: var(--muted)" aria-hidden="true"></div>
<img
src={item.cover}
alt={item.title}
class="w-full h-full object-cover opacity-0 transition-all duration-500 ease-out group-hover:scale-105"
loading="lazy"
decoding="async"
onload={handleLoad}
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--btn-content)">
<span class="text-4xl">🚀</span>
</div>
{/if}
</div>

<!-- 内容 -->
<div class="flex flex-col flex-1 p-4">
<h3 class="font-bold text-base text-neutral-900 dark:text-neutral-100 line-clamp-1 mb-1 group-hover:text-(--primary) transition-colors">
{item.title || "Untitled"}
</h3>
{#if item.description}
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-2 mb-3 flex-1">
{item.description}
</p>
{:else}
<div class="flex-1"></div>
{/if}

<div class="flex items-center justify-between mt-auto pt-2">
{#if visibleTags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each visibleTags as tag}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--btn-regular-bg) text-(--btn-content)">{tag}</span>
{/each}
{#if hiddenTagCount > 0}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--btn-regular-bg) text-(--btn-content)">+{hiddenTagCount}</span>
{/if}
</div>
{:else}
<span></span>
{/if}
{#if item.date}
<span class="text-xs text-neutral-500 dark:text-neutral-500 whitespace-nowrap ml-2">{item.date}</span>
{/if}
</div>
</div>
</div>
{/if}

<style>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
28 changes: 28 additions & 0 deletions src/components/pages/projects/ProjectSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import type { ProjectItem } from "@/types/projectsConfig";
import ProjectCard from "./ProjectCard.svelte";

interface Props {
sectionId: string;
sectionTitle?: string;
items: ProjectItem[];
isActive: boolean;
}

const { sectionId, sectionTitle, items, isActive }: Props = $props();
</script>

<div id="projects-section-{sectionId}" class="{isActive ? 'block' : 'hidden'}">
{#if sectionTitle}
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<span class="w-1 h-5 rounded-full bg-(--primary)"></span>
{sectionTitle}
<span class="text-sm font-normal text-neutral-500 dark:text-neutral-400">({items.length})</span>
</h3>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{#each items as item (item.id)}
<ProjectCard {item} />
{/each}
</div>
</div>
96 changes: 96 additions & 0 deletions src/components/pages/projects/ProjectsGrid.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts">
import { onMount } from "svelte";
import type { ProjectItem } from "@/types/projectsConfig";
import ProjectSection from "./ProjectSection.svelte";
import TabNav from "./TabNav.svelte";

interface Props {
tabs: Array<{ id: string; name: string; count: number }>;
initialActiveTab: string;
projects: Record<string, ProjectItem[]>;
}

const { tabs, initialActiveTab, projects }: Props = $props();

// 改成空字符串初始化,不引用任何 prop,编译器立刻闭嘴
let activeTab = $state("");
let searchKeyword = $state("");

function handleTabChange(tabId: string) {
activeTab = tabId;
}

function handleSearch(keyword: string) {
searchKeyword = keyword;
}

function restoreTabFromHash() {
const hash = window.location.hash.replace(/^#/, "");
if (hash) {
try {
const decoded = decodeURIComponent(hash);
if (tabs.some((t) => t.id === decoded)) {
activeTab = decoded;
}
} catch {}
}
}

function getProjectsForTab(tabId: string): ProjectItem[] {
if (tabId === "__all__") {
return Object.values(projects).flat();
}
return projects[tabId] || [];
}

function getFilteredProjects(items: ProjectItem[]): ProjectItem[] {
const keyword = searchKeyword.trim().toLowerCase();
if (!keyword) return items;

return items.filter((item) => {
const text = [item.title, item.description || "", item.tags?.join(" ") || ""]
.join(" ")
.toLowerCase();
return text.includes(keyword);
});
}

onMount(() => {
// 在客户端一次性设置初始值,同时处理 hash
activeTab = initialActiveTab;
restoreTabFromHash();
});
</script>

{#if tabs.length > 0}
<TabNav {tabs} {activeTab} {searchKeyword} onTabChange={handleTabChange} onSearch={handleSearch} />

{#if activeTab === "__all__"}
<!-- 全部:按分类分行展示 -->
{#each tabs as tab (tab.id)}
{#if tab.id !== "__all__"}
{@const items = getFilteredProjects(projects[tab.id] || [])}
{#if items.length > 0}
<div class="mb-8 last:mb-0">
<ProjectSection
sectionId={tab.id}
sectionTitle={tab.name}
{items}
isActive={true}
/>
</div>
{/if}
{/if}
{/each}
{:else}
<!-- 单个分类 -->
{@const activeTabObj = tabs.find((t) => t.id === activeTab)}
{@const items = getFilteredProjects(getProjectsForTab(activeTab))}
<ProjectSection
sectionId={activeTab}
sectionTitle={activeTabObj?.name}
{items}
isActive={true}
/>
{/if}
{/if}
94 changes: 94 additions & 0 deletions src/components/pages/projects/TabNav.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts">
import Icon from "@/components/common/Icon.svelte";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";

interface Tab {
id: string;
name: string;
count?: number;
}

interface Props {
tabs: Tab[];
activeTab: string;
searchKeyword: string;
onTabChange: (tabId: string) => void;
onSearch: (keyword: string) => void;
}

const { tabs, activeTab, searchKeyword, onTabChange, onSearch }: Props = $props();

function handleHashChange() {
const hash = window.location.hash.replace(/^#/, "");
if (hash) {
try {
const decoded = decodeURIComponent(hash);
if (tabs.some((t) => t.id === decoded)) {
onTabChange(decoded);
}
} catch {}
}
}

$effect(() => {
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
});

function clickTab(tabId: string) {
onTabChange(tabId);
if (tabId === "__all__") {
if (window.location.hash) {
window.history.replaceState(null, "", window.location.pathname + window.location.search);
}
return;
}
const nextHash = `#${encodeURIComponent(tabId)}`;
if (window.location.hash !== nextHash) {
window.history.replaceState(null, "", nextHash);
}
}

function handleInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
onSearch(value);
}
</script>

<div class="mb-6">
<!-- 搜索框 -->
<div class="relative max-w-md mb-4">
<Icon icon="material-symbols:search" class="absolute left-3 top-1/2 -translate-y-1/2 text-[1.25rem] text-neutral-900 dark:text-neutral-100" />
<input
type="text"
value={searchKeyword}
placeholder={i18n(I18nKey.search)}
oninput={handleInput}
class="w-full pl-10 pr-4 py-2 rounded-lg bg-white dark:bg-neutral-800 border border-(--line-divider) text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-500 focus:outline-none focus:border-(--primary) transition-colors"
/>
</div>

<div class="border-b border-(--line-divider)">
<div class="overflow-x-auto" data-tab-scroll-container>
<nav class="flex min-w-max space-x-8" aria-label="Project tabs">
{#each tabs as tab}
<button
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200 {tab.id === activeTab
? 'border-(--primary) text-(--primary)'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'}"
onclick={() => clickTab(tab.id)}
type="button"
>
{tab.name}
{#if tab.count !== undefined}
<span class="ml-2 bg-(--btn-regular-bg) text-(--btn-content) py-0.5 px-2 rounded-full text-xs">
{tab.count}
</span>
{/if}
</button>
{/each}
</nav>
</div>
</div>
</div>
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export { licenseConfig } from "./licenseConfig"; // 许可证配置
export { mermaidConfig } from "./mermaidConfig"; // Mermaid 图表配置
export { musicPlayerConfig } from "./musicConfig"; // 音乐播放器配置
export { navBarConfig, navBarSearchConfig } from "./navBarConfig"; // 导航栏配置与搜索配置
export { projectsConfig } from "./projectsConfig"; // 项目页面配置
export { live2dWidgetConfig, spineModelConfig } from "./pioConfig"; // 看板娘配置
export { plantumlConfig } from "./plantumlConfig"; // PlantUML 图表配置
export { profileConfig } from "./profileConfig"; // 用户资料配置
Expand Down
Loading