mirror of
https://github.com/KazooTTT/kazoottt-blog.git
synced 2025-06-23 02:31:33 +08:00
feat: add dir tree
This commit is contained in:
@ -30,7 +30,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
redirects: {
|
redirects: {
|
||||||
'/articles': {
|
'/article': {
|
||||||
status: 301,
|
status: 301,
|
||||||
destination: '/posts'
|
destination: '/posts'
|
||||||
},
|
},
|
||||||
|
113
src/components/CategoriesView.tsx
Normal file
113
src/components/CategoriesView.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import type { CategoryHierarchy } from '@/types'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface CategoriesViewProps {
|
||||||
|
allCategories: [string, number][]
|
||||||
|
allCategoriesHierarchy: CategoryHierarchy[]
|
||||||
|
defaultViewMode?: 'tree' | 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoriesView: React.FC<CategoriesViewProps> = ({
|
||||||
|
allCategories,
|
||||||
|
allCategoriesHierarchy,
|
||||||
|
defaultViewMode = 'tree'
|
||||||
|
}) => {
|
||||||
|
const [viewMode, setViewMode] = useState<'tree' | 'list'>(defaultViewMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='mb-6 mt-5 flex items-center justify-between'>
|
||||||
|
<h1 className='text-2xl font-bold'>Categories</h1>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-x-2'>
|
||||||
|
<span className='text-sm'>View:</span>
|
||||||
|
<div className='flex overflow-hidden rounded-md border'>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('tree')}
|
||||||
|
className={`view-mode-btn px-2 py-1 text-sm
|
||||||
|
${viewMode === 'tree' ? 'bg-primary text-white' : 'bg-white text-gray-700'}
|
||||||
|
border-r`}
|
||||||
|
>
|
||||||
|
Tree
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`view-mode-btn px-2 py-1 text-sm
|
||||||
|
${viewMode === 'list' ? 'bg-primary text-white' : 'bg-white text-gray-700'}
|
||||||
|
border-l`}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allCategoriesHierarchy.length === 0 && <p>No posts yet.</p>}
|
||||||
|
|
||||||
|
{/* List View */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className='flex flex-col gap-y-3'>
|
||||||
|
{allCategories.map(([category, count]) => (
|
||||||
|
<div key={category} className='flex items-center gap-x-2'>
|
||||||
|
<a
|
||||||
|
className='inline-block underline underline-offset-4 hover:text-foreground/75'
|
||||||
|
href={`/categories/${category}/`}
|
||||||
|
title={`View posts of the Category: ${category}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</a>
|
||||||
|
<span className='inline-block'>
|
||||||
|
- {count} post{count > 1 && 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tree View */}
|
||||||
|
{viewMode === 'tree' && (
|
||||||
|
<div className='flex flex-col gap-y-3'>
|
||||||
|
{allCategoriesHierarchy.map((category) => (
|
||||||
|
<div key={category.fullCategory}>
|
||||||
|
{/* Render root-level categories */}
|
||||||
|
<div className='flex items-center gap-x-2'>
|
||||||
|
<a
|
||||||
|
className='inline-block underline underline-offset-4 hover:text-foreground/75'
|
||||||
|
href={`/categories/${category.fullCategory}/`}
|
||||||
|
title={`View posts of the Category: ${category.fullCategory}`}
|
||||||
|
>
|
||||||
|
{category.category}
|
||||||
|
</a>
|
||||||
|
<span className='inline-block'>
|
||||||
|
- {category.count} post{category.count > 1 && 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render nested categories with indentation */}
|
||||||
|
{category.children && Object.values(category.children).length > 0 && (
|
||||||
|
<div className='pl-8'>
|
||||||
|
{Object.values(category.children).map((childCategory) => (
|
||||||
|
<div key={childCategory.fullCategory} className='flex items-center gap-x-2'>
|
||||||
|
<a
|
||||||
|
className='inline-block underline underline-offset-4 hover:text-foreground/75'
|
||||||
|
href={`/categories/${childCategory.fullCategory}/`}
|
||||||
|
title={`View posts of the Category: ${childCategory.fullCategory}`}
|
||||||
|
>
|
||||||
|
{childCategory.category}
|
||||||
|
</a>
|
||||||
|
<span className='inline-block'>
|
||||||
|
- {childCategory.count} post{childCategory.count > 1 && 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoriesView
|
@ -8,20 +8,25 @@ import Button from '@/components/Button.astro'
|
|||||||
import Pagination from '@/components/Paginator.astro'
|
import Pagination from '@/components/Paginator.astro'
|
||||||
import PostPreview from '@/components/blog/PostPreview.astro'
|
import PostPreview from '@/components/blog/PostPreview.astro'
|
||||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||||
import { getAllPosts, getUniqueCategories, getUniqueTags, sortMDByDate } from '@/utils'
|
import {
|
||||||
|
getAllPosts,
|
||||||
|
getUniqueCategoriesWithCount,
|
||||||
|
getUniqueTagsWithCount,
|
||||||
|
sortMDByDate
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
export const getStaticPaths = (async ({ paginate }) => {
|
export const getStaticPaths = (async ({ paginate }) => {
|
||||||
const allPosts = await getAllPosts()
|
const allPosts = await getAllPosts()
|
||||||
const allPostsByDate = sortMDByDate(allPosts)
|
const allPostsByDate = sortMDByDate(allPosts)
|
||||||
const uniqueTags = getUniqueTags(allPosts)
|
const uniqueTags = getUniqueTagsWithCount(allPosts)
|
||||||
const uniqueCategories = getUniqueCategories(allPosts)
|
const uniqueCategories = getUniqueCategoriesWithCount(allPosts)
|
||||||
return paginate(allPostsByDate, { pageSize: 50, props: { uniqueTags, uniqueCategories } })
|
return paginate(allPostsByDate, { pageSize: 50, props: { uniqueTags, uniqueCategories } })
|
||||||
}) satisfies GetStaticPaths
|
}) satisfies GetStaticPaths
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
page: Page<CollectionEntry<'post'>>
|
page: Page<CollectionEntry<'post'>>
|
||||||
uniqueTags: string[]
|
uniqueTags: Array<[string, number]>
|
||||||
uniqueCategories: string[]
|
uniqueCategories: Array<[string, number]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, uniqueTags, uniqueCategories } = Astro.props
|
const { page, uniqueTags, uniqueCategories } = Astro.props
|
||||||
@ -104,7 +109,11 @@ const paginationProps = {
|
|||||||
<ul class='text-bgColor flex flex-wrap gap-2'>
|
<ul class='text-bgColor flex flex-wrap gap-2'>
|
||||||
{uniqueCategories.slice(0, 6).map((category) => (
|
{uniqueCategories.slice(0, 6).map((category) => (
|
||||||
<li>
|
<li>
|
||||||
<Button title={category} href={`/categories/${category}/`} style='pill' />
|
<Button
|
||||||
|
title={category[0]}
|
||||||
|
href={`/categories/${category[0]}/`}
|
||||||
|
style='pill'
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -145,7 +154,7 @@ const paginationProps = {
|
|||||||
<ul class='text-bgColor flex flex-wrap gap-2'>
|
<ul class='text-bgColor flex flex-wrap gap-2'>
|
||||||
{uniqueTags.slice(0, 6).map((tag) => (
|
{uniqueTags.slice(0, 6).map((tag) => (
|
||||||
<li>
|
<li>
|
||||||
<Button title={tag} href={`/tags/${tag}/`} style='pill' />
|
<Button title={tag[0]} href={`/tags/${tag[0]}/`} style='pill' />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -8,16 +8,26 @@ import Pagination from '@/components/Paginator.astro'
|
|||||||
import PostPreview from '@/components/blog/PostPreview.astro'
|
import PostPreview from '@/components/blog/PostPreview.astro'
|
||||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||||
import Button from '@/components/Button.astro'
|
import Button from '@/components/Button.astro'
|
||||||
import { getAllPosts, getUniqueCategories, sortMDByDate } from '@/utils'
|
import { getAllPosts, sortMDByDate } from '@/utils'
|
||||||
|
import { getCategoriesGroupByName } from 'src/utils/post'
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
|
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
|
||||||
const allPosts = await getAllPosts()
|
const allPosts = await getAllPosts()
|
||||||
const allPostsByDate = sortMDByDate(allPosts)
|
const allPostsByDate = sortMDByDate(allPosts)
|
||||||
const uniqueCategories = getUniqueCategories(allPostsByDate)
|
const categoriesHierarchy = getCategoriesGroupByName(allPostsByDate)
|
||||||
|
|
||||||
return uniqueCategories.flatMap((category) => {
|
// Flatten the hierarchy to get all possible category paths
|
||||||
|
const allCategories = categoriesHierarchy.flatMap((category) => {
|
||||||
|
const result = [category.fullCategory]
|
||||||
|
Object.values(category.children).forEach((child) => {
|
||||||
|
result.push(child.fullCategory)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return allCategories.flatMap((category) => {
|
||||||
const filterPosts = allPostsByDate.filter((post) =>
|
const filterPosts = allPostsByDate.filter((post) =>
|
||||||
category === '未分类' ? !post.data.category : post.data.category === category
|
category === '未分类' ? !post.data?.category : post.data.category?.startsWith(category)
|
||||||
)
|
)
|
||||||
return paginate(filterPosts, {
|
return paginate(filterPosts, {
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
@ -1,15 +1,22 @@
|
|||||||
---
|
---
|
||||||
import Button from '@/components/Button.astro'
|
import Button from '@/components/Button.astro'
|
||||||
|
import type { CategoryHierarchy } from 'src/types'
|
||||||
import PageLayout from '@/layouts/BaseLayout.astro'
|
import PageLayout from '@/layouts/BaseLayout.astro'
|
||||||
import { getAllPosts, getUniqueCategoriesWithCount } from '@/utils'
|
import { getAllPosts, getUniqueCategoriesWithCount } from '@/utils'
|
||||||
|
import { getCategoriesGroupByName } from 'src/utils/post'
|
||||||
|
import CategoriesView from 'src/components/CategoriesView'
|
||||||
|
|
||||||
const allPosts = await getAllPosts()
|
const allPosts = await getAllPosts()
|
||||||
const allCategories = getUniqueCategoriesWithCount(allPosts)
|
const allCategories = getUniqueCategoriesWithCount(allPosts)
|
||||||
|
const allCategoriesHierarchy = getCategoriesGroupByName(allPosts) as CategoryHierarchy[]
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
description: "A list of all the topics I've written about in my posts",
|
description: "A list of all the topics I've written about in my posts",
|
||||||
title: 'All Categories'
|
title: 'All Categories'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default to tree view
|
||||||
|
const defaultViewMode = 'tree'
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout meta={meta}>
|
<PageLayout meta={meta}>
|
||||||
@ -29,30 +36,11 @@ const meta = {
|
|||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
<CategoriesView
|
||||||
<h1 class='mb-6 mt-5 text-2xl font-bold'>Categories</h1>
|
client:load
|
||||||
{allCategories.length === 0 && <p>No posts yet.</p>}
|
allCategories={allCategories}
|
||||||
|
allCategoriesHierarchy={allCategoriesHierarchy}
|
||||||
{
|
defaultViewMode={defaultViewMode}
|
||||||
allCategories.length > 0 && (
|
/>
|
||||||
<ul class='flex flex-col gap-y-3'>
|
|
||||||
{allCategories.map(([category, val]) => (
|
|
||||||
<li class='flex items-center gap-x-2 '>
|
|
||||||
<a
|
|
||||||
class='inline-block underline underline-offset-4 hover:text-foreground/75'
|
|
||||||
data-astro-prefetch
|
|
||||||
href={`/categories/${category}/`}
|
|
||||||
title={`View posts of the Category: ${category}`}
|
|
||||||
>
|
|
||||||
#{category}
|
|
||||||
</a>
|
|
||||||
<span class='inline-block'>
|
|
||||||
- {val} post{val > 1 && 's'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
@ -23,3 +23,11 @@ export type SiteMeta = {
|
|||||||
ogImage?: string | undefined
|
ogImage?: string | undefined
|
||||||
articleDate?: string | undefined
|
articleDate?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface CategoryHierarchy {
|
||||||
|
category: string
|
||||||
|
fullCategory: string
|
||||||
|
children: Record<string, CategoryHierarchy>
|
||||||
|
count: number
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { CategoryHierarchy } from '@/types'
|
||||||
import type { CollectionEntry } from 'astro:content'
|
import type { CollectionEntry } from 'astro:content'
|
||||||
import { getCollection } from 'astro:content'
|
import { getCollection } from 'astro:content'
|
||||||
|
|
||||||
@ -82,6 +83,83 @@ export function getUniqueCategoriesWithCount(
|
|||||||
].sort((a, b) => b[1] - a[1])
|
].sort((a, b) => b[1] - a[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCategoriesGroupByName(posts: Array<CollectionEntry<'post'>>): CategoryHierarchy[] {
|
||||||
|
const categories = getUniqueCategoriesWithCount(posts)
|
||||||
|
const hierarchicalCategories: CategoryHierarchy[] = []
|
||||||
|
|
||||||
|
categories.forEach(([fullName, count]) => {
|
||||||
|
const parts = fullName.split('-')
|
||||||
|
let current = hierarchicalCategories
|
||||||
|
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
// If it's the last part, add count
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
// Check if category already exists
|
||||||
|
let categoryObj = current.find(cat => cat.category === parts[0])
|
||||||
|
|
||||||
|
if (!categoryObj) {
|
||||||
|
categoryObj = {
|
||||||
|
category: parts[0],
|
||||||
|
fullCategory: parts[0],
|
||||||
|
children: {},
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
current.push(categoryObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a nested category
|
||||||
|
if (parts.length > 1) {
|
||||||
|
if (!categoryObj.children[part]) {
|
||||||
|
categoryObj.children[part] = {
|
||||||
|
category: part,
|
||||||
|
fullCategory: `${categoryObj.fullCategory}-${part}`,
|
||||||
|
children: {},
|
||||||
|
count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
categoryObj.children[part].count = count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Top-level category
|
||||||
|
categoryObj.count = count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure top-level category exists
|
||||||
|
let categoryObj = current.find(cat => cat.fullCategory === part)
|
||||||
|
if (!categoryObj) {
|
||||||
|
categoryObj = {
|
||||||
|
category: part,
|
||||||
|
fullCategory: part,
|
||||||
|
children: {},
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
current.push(categoryObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate total count for each category by summing subcategories
|
||||||
|
hierarchicalCategories.forEach(category => {
|
||||||
|
if (Object.keys(category.children).length > 0) {
|
||||||
|
category.count = Object.values(category.children)
|
||||||
|
.reduce((sum, child) => sum + (child.count || 0), 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out categories with zero count and sort by count
|
||||||
|
return hierarchicalCategories
|
||||||
|
.filter(category => category.count > 0)
|
||||||
|
.map(category => ({
|
||||||
|
...category,
|
||||||
|
children: Object.fromEntries(
|
||||||
|
Object.entries(category.children)
|
||||||
|
.filter(([_, child]) => child.count > 0)
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
|
||||||
export function getIdToSlugMap(posts: Array<CollectionEntry<'post'>>): Record<string, string> {
|
export function getIdToSlugMap(posts: Array<CollectionEntry<'post'>>): Record<string, string> {
|
||||||
return posts.reduce(
|
return posts.reduce(
|
||||||
(acc, post) => {
|
(acc, post) => {
|
||||||
|
Reference in New Issue
Block a user