Skip to content
Open
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
13 changes: 10 additions & 3 deletions src/components/ArchivePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,16 @@ onMount(async () => {
}

if (categories.length > 0) {
filteredPosts = filteredPosts.filter(
(post) => post.data.category && categories.includes(post.data.category),
);
filteredPosts = filteredPosts.filter((post) => {
if (!post.data.category) return false;
const postCategory = post.data.category;
// 检查文章的分类是否以任何筛选分类开头(包含子分类)
return categories.some(
(filterCategory) =>
postCategory === filterCategory ||
postCategory.startsWith(filterCategory + "/"),
);
});
}

if (uncategorized) {
Expand Down
18 changes: 5 additions & 13 deletions src/components/widget/Categories.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getCategoryList } from "../../utils/content-utils";
import ButtonLink from "../control/ButtonLink.astro";
import { getNestedCategoryList } from "../../utils/content-utils";
import CategoryItem from "./CategoryItem.astro";
import WidgetLayout from "./WidgetLayout.astro";

const categories = await getCategoryList();
const categories = await getNestedCategoryList();

const COLLAPSED_HEIGHT = "7.5rem";
const COLLAPSE_THRESHOLD = 5;
Expand All @@ -23,13 +23,5 @@ const style = Astro.props.style;
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
class={className} style={style}
>
{categories.map((c) =>
<ButtonLink
url={c.url}
badge={String(c.count)}
label={`View all posts in the ${c.name.trim()} category`}
>
{c.name.trim()}
</ButtonLink>
)}
</WidgetLayout>
{categories.map((category) => <CategoryItem category={category} />)}
</WidgetLayout>
46 changes: 46 additions & 0 deletions src/components/widget/CategoryBranch.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
import { Icon } from "astro-icon/components";
import type { CategoryNode } from "../../utils/content-utils";
import ButtonLink from "../control/ButtonLink.astro";
// 递归调用:用别名避免与编译器内部组件名冲突
import CategoryBranchRecursive from "./CategoryBranch.astro";

interface Props {
node: CategoryNode;
}
const { node } = Astro.props;
const hasChildren = node.children.length > 0;
---
<div class="category-node">
<div class="flex items-center w-full">
<div class="flex items-center justify-center w-6 h-6 flex-shrink-0">
{hasChildren && (
<div class="flex items-center justify-center w-6 h-6 rounded-md transition-all duration-200 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] category-toggle-container cursor-pointer" data-toggle>
<Icon
name="fa6-solid:angle-right"
class="w-3 h-3 category-toggle transition-all duration-300 text-black/50 hover:text-[var(--primary)] dark:text-white/50 dark:hover:text-[var(--primary)] hover:scale-110 transform-gpu pointer-events-none"
role="button"
aria-label={`Toggle children of ${node.name}`}
aria-expanded="false"
/>
</div>
)}
</div>
<div class="flex-1 min-w-0">
<ButtonLink
url={node.url}
badge={String(node.count)}
label={`View all posts in the ${node.name.trim()} category`}
>
{node.name.trim()}
</ButtonLink>
</div>
</div>
{hasChildren && (
<div class="ml-6 category-children grid grid-rows-[0fr] transition-all duration-300 ease-in-out">
<div class="overflow-hidden">
{node.children.map(child => <CategoryBranchRecursive node={child} />)}
</div>
</div>
)}
</div>
72 changes: 72 additions & 0 deletions src/components/widget/CategoryItem.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
import type { CategoryNode } from "../../utils/content-utils";
import CategoryBranch from "./CategoryBranch.astro";

interface Props {
category: CategoryNode;
}
const { category } = Astro.props;
---
<div class="category-item">
<CategoryBranch node={category} />
</div>

<script>
// 全局事件委托(兼容 swup/局部刷新),只初始化一次
(function initCategoryTree(){
if ((window as any).__catTreeInited) return; // 避免重复绑定
(window as any).__catTreeInited = true;
const LOG_PREFIX = '[CategoryTree]';
function log(...args:any[]){ if (typeof console !== 'undefined') console.debug(LOG_PREFIX, ...args); }

function toggleNode(toggleEl: HTMLElement) {
const node = toggleEl.closest('.category-node');
if (!node) return;
const children = node.querySelector(':scope > .category-children') as HTMLElement | null;
if (!children) return;

const isExpanded = children.classList.contains('grid-rows-[1fr]');
log('toggle', { name: toggleEl.getAttribute('aria-label'), expanded: !isExpanded });

if (isExpanded) {
// 收起
children.classList.remove('grid-rows-[1fr]');
children.classList.add('grid-rows-[0fr]');
toggleEl.classList.remove('rotate-90');
toggleEl.setAttribute('aria-expanded', 'false');
} else {
// 展开
children.classList.remove('grid-rows-[0fr]');
children.classList.add('grid-rows-[1fr]');
toggleEl.classList.add('rotate-90');
toggleEl.setAttribute('aria-expanded', 'true');
}
}

// 点击委托
document.addEventListener('click', (e) => {
const target = (e.target as HTMLElement).closest('.category-toggle-container') as HTMLElement | null;
if (target) {
e.preventDefault();
e.stopPropagation();
const icon = target.querySelector('.category-toggle') as HTMLElement | null;
if (icon) {
toggleNode(icon);
}
}
});

// 键盘
document.addEventListener('keydown', (e) => {
if ((e.key === 'Enter' || e.key === ' ') && (e.target as HTMLElement).classList?.contains('category-toggle-container')) {
e.preventDefault();
const icon = (e.target as HTMLElement).querySelector('.category-toggle') as HTMLElement | null;
if (icon) {
toggleNode(icon);
}
}
});

log('initialized');
})();
</script>
21 changes: 21 additions & 0 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,24 @@
.collapsed {
height: var(--collapsedHeight);
}

/* Category tree icon styles */
.category-toggle {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.category-toggle.rotate-90 {
transform: rotate(90deg);
}

.category-node .category-children {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.category-toggle:hover {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}

:root.dark .category-toggle:hover {
filter: drop-shadow(0 2px 4px rgba(255, 255, 255, 0.1));
}
83 changes: 83 additions & 0 deletions src/utils/content-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ export type Category = {
url: string;
};

export interface CategoryNode {
name: string;
count: number;
url: string;
fullPath: string;
children: CategoryNode[];
}

export async function getCategoryList(): Promise<Category[]> {
const allBlogPosts = await getCollection<"posts">("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
Expand Down Expand Up @@ -112,3 +120,78 @@ export async function getCategoryList(): Promise<Category[]> {
}
return ret;
}

export async function getNestedCategoryList(): Promise<CategoryNode[]> {
const categories = await getCategoryList();
return buildCategoryTree(categories);
}

function buildCategoryTree(categories: Category[]): CategoryNode[] {
const root: CategoryNode[] = [];
const map: { [key: string]: CategoryNode } = {};

// 第一遍:创建所有节点
categories.forEach((category) => {
const parts = category.name.split("/");
let currentPath = "";

for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;

if (!map[currentPath]) {
const node: CategoryNode = {
name: part,
count: 0,
url: getCategoryUrl(currentPath),
fullPath: currentPath,
children: [],
};
map[currentPath] = node;

// 如果是根节点,添加到根数组
if (i === 0) {
root.push(node);
}
}

// 如果是完整路径,设置计数
if (i === parts.length - 1) {
map[currentPath].count = category.count;
}
}
});

// 第二遍:构建父子关系
Object.keys(map).forEach((path) => {
const node = map[path];
const parts = path.split("/");

if (parts.length > 1) {
const parentPath = parts.slice(0, -1).join("/");
const parent = map[parentPath];
if (parent && !parent.children.find((child) => child.fullPath === path)) {
parent.children.push(node);
}
}
});

// 第三遍:递归计算总数(包含子分类的文章数量)
function calculateTotalCount(node: CategoryNode): number {
let total = node.count; // 当前分类的直接文章数量

// 累加所有子分类的文章数量
for (const child of node.children) {
total += calculateTotalCount(child);
}

node.count = total; // 更新为总数
return total;
}

// 计算每个根节点的总数
root.forEach(calculateTotalCount);

return root;
}