feat(theme): 新增 博客文章分类 支持
This commit is contained in:
parent
7efa63fe31
commit
c1a4d586b4
@ -116,8 +116,8 @@ export const zhNotes = definePlumeNotesConfig({
|
||||
{
|
||||
text: 'plugin-netlify-functions',
|
||||
dir: 'netlify-functions',
|
||||
link: 'plugin-netlify-functions/',
|
||||
collapsed: false,
|
||||
link: '/plugins/plugin-netlify-functions/',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'介绍',
|
||||
'使用',
|
||||
|
||||
@ -98,6 +98,12 @@ interface BlogOptions {
|
||||
* @default true
|
||||
*/
|
||||
archives?: boolean
|
||||
|
||||
/**
|
||||
* 是否启用分类页
|
||||
* @default true
|
||||
*/
|
||||
categories?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -110,10 +110,12 @@ title: 标题
|
||||
这里的内容不会被作为摘要
|
||||
```
|
||||
|
||||
## 标签页和归档页
|
||||
## 标签页,分类页和归档页
|
||||
|
||||
主题除了自动生成 **博客文章列表页** 以外,还会自动生成 **标签页** 和 **归档页**。
|
||||
主题除了自动生成 **博客文章列表页** 以外,还会自动生成 **标签页**,**分类页** 和 **归档页**。
|
||||
|
||||
标签页 可以根据 标签 筛选并展示 博客文章。
|
||||
|
||||
分类页 可以根据 原始目录结构 分类展示 博客文章。
|
||||
|
||||
归档页根据文章的创建时间进行归档。
|
||||
|
||||
@ -4,6 +4,7 @@ import VPBlogArchives from '@theme/Blog/VPBlogArchives.vue'
|
||||
import VPBlogAside from '@theme/Blog/VPBlogAside.vue'
|
||||
import VPBlogExtract from '@theme/Blog/VPBlogExtract.vue'
|
||||
import VPBlogTags from '@theme/Blog/VPBlogTags.vue'
|
||||
import VPBlogCategories from '@theme/Blog/VPBlogCategories.vue'
|
||||
import VPBlogNav from '@theme/Blog/VPBlogNav.vue'
|
||||
import VPTransitionFadeSlideY from '@theme/VPTransitionFadeSlideY.vue'
|
||||
import { useData } from '../../composables/data.js'
|
||||
@ -35,6 +36,14 @@ const { theme, page } = useData()
|
||||
<slot name="blog-tags-after" />
|
||||
</template>
|
||||
</VPBlogTags>
|
||||
<VPBlogCategories v-else-if="page.type === 'blog-categories'">
|
||||
<template #blog-categories-before>
|
||||
<slot name="blog-categories-before" />
|
||||
</template>
|
||||
<template #blog-categories-after>
|
||||
<slot name="blog-tags-after" />
|
||||
</template>
|
||||
</VPBlogCategories>
|
||||
<VPPostList v-else>
|
||||
<template #blog-post-list-before>
|
||||
<slot name="blog-post-list-before" />
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import VPShortPostList from '@theme/Blog/VPShortPostList.vue'
|
||||
import { useBlogExtract } from '../../composables/blog-extract.js'
|
||||
import { useBlogNavTitle } from '../../composables/blog-extract.js'
|
||||
import { useArchives } from '../../composables/blog-archives.js'
|
||||
|
||||
const { archives: archivesLink } = useBlogExtract()
|
||||
const title = useBlogNavTitle('archive')
|
||||
const { archives } = useArchives()
|
||||
</script>
|
||||
|
||||
@ -13,7 +13,7 @@ const { archives } = useArchives()
|
||||
|
||||
<h2 class="archives-title">
|
||||
<span class="vpi-archive icon" />
|
||||
<span>{{ archivesLink.text }}</span>
|
||||
<span>{{ title }}</span>
|
||||
</h2>
|
||||
<div v-if="archives.length" class="archives">
|
||||
<template v-for="archive in archives" :key="archive.label">
|
||||
|
||||
81
theme/src/client/components/Blog/VPBlogCategories.vue
Normal file
81
theme/src/client/components/Blog/VPBlogCategories.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import VPCategories from '@theme/Blog/VPCategories.vue'
|
||||
import { useBlogCategory } from '../../composables/blog-category.js'
|
||||
import { useBlogNavTitle } from '../../composables/blog-extract.js'
|
||||
|
||||
const title = useBlogNavTitle('category')
|
||||
const { categories } = useBlogCategory()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vp-blog-categories">
|
||||
<slot name="blog-categories-before" />
|
||||
|
||||
<h2 class="categories-title">
|
||||
<span class="vpi-category icon" />
|
||||
<span>{{ title }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="content">
|
||||
<VPCategories :items="categories" />
|
||||
</div>
|
||||
|
||||
<slot name="blog-categories-after" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vp-blog-categories {
|
||||
flex: 1;
|
||||
padding: 32px 0;
|
||||
margin: 0 auto;
|
||||
transition: background-color var(--t-color), box-shadow var(--t-color);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.vp-blog-categories {
|
||||
padding: 20px 0;
|
||||
margin: 32px auto 32px 20px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
}
|
||||
|
||||
.vp-blog-categories:hover {
|
||||
box-shadow: var(--vp-shadow-2);
|
||||
}
|
||||
}
|
||||
|
||||
.categories-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color var(--t-color);
|
||||
}
|
||||
|
||||
.categories-title .icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.categories-title {
|
||||
padding-bottom: 20px;
|
||||
margin-top: 0;
|
||||
border-bottom: solid 1px var(--vp-c-divider);
|
||||
transition: border-bottom var(--t-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.vp-blog-categories {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vp-blog-categories .content {
|
||||
padding: 20px 20px 0;
|
||||
}
|
||||
</style>
|
||||
@ -21,7 +21,7 @@ const imageUrl = computed(() => {
|
||||
return withBase(url)
|
||||
})
|
||||
|
||||
const { hasBlogExtract, tags, archives } = useBlogExtract()
|
||||
const { hasBlogExtract, tags, archives, categories } = useBlogExtract()
|
||||
const open = ref(false)
|
||||
const lazyOpen = ref(false)
|
||||
|
||||
@ -92,6 +92,10 @@ const showBlogExtract = computed(() => {
|
||||
<span class="vpi-tag icon" />
|
||||
<span>{{ tags.text }}</span>
|
||||
</VPLink>
|
||||
<VPLink class="nav-link" :href="categories.link" no-icon>
|
||||
<span class="vpi-category icon" />
|
||||
<span>{{ categories.text }}</span>
|
||||
</VPLink>
|
||||
<VPLink class="nav-link" :href="archives.link" no-icon>
|
||||
<span class="vpi-archive icon" />
|
||||
<span>{{ archives.text }}</span>
|
||||
|
||||
@ -9,7 +9,7 @@ const props = defineProps<{
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { hasBlogExtract, tags, archives } = useBlogExtract()
|
||||
const { hasBlogExtract, tags, archives, categories } = useBlogExtract()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -24,6 +24,16 @@ const { hasBlogExtract, tags, archives } = useBlogExtract()
|
||||
<span class="total">{{ tags.total }}</span>
|
||||
<span class="icon vpi-chevron-right" />
|
||||
</VPLink>
|
||||
<VPLink
|
||||
class="nav-link"
|
||||
:class="{ active: route.path === categories.link }"
|
||||
:href="categories.link"
|
||||
>
|
||||
<span class="icon icon-logo vpi-category" />
|
||||
<span class="text">{{ categories.text }}</span>
|
||||
<span class="total">{{ categories.total }}</span>
|
||||
<span class="icon vpi-chevron-right" />
|
||||
</VPLink>
|
||||
<VPLink
|
||||
class="nav-link"
|
||||
:class="{ active: route.path === archives.link }"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import VPShortPostList from '@theme/Blog/VPShortPostList.vue'
|
||||
import { useBlogExtract } from '../../composables/blog-extract.js'
|
||||
import { useBlogNavTitle } from '../../composables/blog-extract.js'
|
||||
import { useTags } from '../../composables/blog-tags.js'
|
||||
|
||||
const { tags, currentTag, postList, handleTagClick } = useTags()
|
||||
const { tags: tagsLink } = useBlogExtract()
|
||||
const title = useBlogNavTitle('tag')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -14,7 +14,7 @@ const { tags: tagsLink } = useBlogExtract()
|
||||
<div class="tags-nav">
|
||||
<h2 class="tags-title">
|
||||
<span class="vpi-tag icon" />
|
||||
<span>{{ tagsLink.text }}</span>
|
||||
<span>{{ title }}</span>
|
||||
</h2>
|
||||
<div class="tags">
|
||||
<p
|
||||
|
||||
60
theme/src/client/components/Blog/VPCategories.vue
Normal file
60
theme/src/client/components/Blog/VPCategories.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import VPLink from '@theme/VPLink.vue'
|
||||
import VPCategoriesGroup from '@theme/Blog/VPCategoriesGroup.vue'
|
||||
import type { BlogCategoryItem, BlogCategoryItemWithPost } from '../../composables/blog-category.js'
|
||||
|
||||
defineProps < {
|
||||
items: (BlogCategoryItem | BlogCategoryItemWithPost)[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="vp-categories">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="(item as BlogCategoryItemWithPost).path || (item as BlogCategoryItem).id"
|
||||
class="vp-categories-item"
|
||||
>
|
||||
<p v-if="item.type === 'post'" class="post">
|
||||
<span class="vpi-post" />
|
||||
<VPLink
|
||||
:href="item.path"
|
||||
:text="item.title"
|
||||
/>
|
||||
</p>
|
||||
<VPCategoriesGroup v-else :item="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vp-categories-item {
|
||||
margin: 8px 0;
|
||||
font-size: 16px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vp-categories-item .post {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color var(--t-color);
|
||||
}
|
||||
|
||||
.vp-categories-item .post:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-categories-item .post .vpi-post {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.vp-categories-item .post :deep(.vp-link) {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
115
theme/src/client/components/Blog/VPCategoriesGroup.vue
Normal file
115
theme/src/client/components/Blog/VPCategoriesGroup.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vuepress/client'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import VPCategories from '@theme/Blog/VPCategories.vue'
|
||||
import type { BlogCategoryItem } from '../../composables/blog-category.js'
|
||||
|
||||
const props = defineProps<{
|
||||
item: BlogCategoryItem
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const el = ref<HTMLDivElement | null>(null)
|
||||
const active = ref(true)
|
||||
const isActive = ref(false)
|
||||
|
||||
watch(
|
||||
() => [route.query, props.item],
|
||||
() => {
|
||||
const id = route.query.id as string
|
||||
if (!id) {
|
||||
active.value = true
|
||||
}
|
||||
else {
|
||||
active.value = hasActive(props.item, id)
|
||||
}
|
||||
isActive.value = id ? props.item.id === id : false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function hasActive(item: BlogCategoryItem, id: string) {
|
||||
return item.id === id
|
||||
|| item.items.filter(item => item.type === 'category').some(item => hasActive(item, id))
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
active.value = !active.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (el.value && isActive.value) {
|
||||
el.value.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="vp-category-group" :class="{ active }">
|
||||
<p class="folder" @click="toggle">
|
||||
<span class="icon" :class="[active ? 'vpi-folder-open' : 'vpi-folder']" />
|
||||
<span>{{ item.title }}</span>
|
||||
</p>
|
||||
|
||||
<VPCategories v-if="item.items.length" class="group" :items="item.items" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vp-category-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vp-category-group::after {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
display: block;
|
||||
content: "";
|
||||
border-left: 1px solid var(--vp-c-divider);
|
||||
transition: border var(--t-color);
|
||||
}
|
||||
|
||||
.vp-category-group .folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
transition: color var(--t-color);
|
||||
}
|
||||
|
||||
.vp-category-group .folder:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.vp-category-group .folder {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.vp-category-group .folder .icon {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.vp-category-group > .group {
|
||||
display: none;
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.vp-category-group > .group {
|
||||
margin-left: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.vp-category-group.active > .group {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -10,6 +10,16 @@ const props = defineProps<{
|
||||
|
||||
const colors = useTagColors()
|
||||
|
||||
const sticky = computed(() => {
|
||||
if (typeof props.post.sticky === 'boolean') {
|
||||
return props.post.sticky
|
||||
}
|
||||
else if (typeof props.post.sticky === 'number') {
|
||||
return props.post.sticky >= 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const categoryList = computed(() =>
|
||||
props.post.categoryList ?? [],
|
||||
)
|
||||
@ -31,10 +41,7 @@ const createTime = computed(() =>
|
||||
<template>
|
||||
<div class="vp-blog-post-item">
|
||||
<h3>
|
||||
<div
|
||||
v-if="typeof post.sticky === 'boolean' ? post.sticky : post.sticky >= 0"
|
||||
class="sticky"
|
||||
>
|
||||
<div v-if="sticky" class="sticky">
|
||||
TOP
|
||||
</div>
|
||||
<span v-if="post.encrypt" class="icon-lock vpi-lock" />
|
||||
|
||||
@ -10,7 +10,7 @@ const { page, frontmatter } = useData()
|
||||
|
||||
const { isScreenOpen, closeScreen, toggleScreen } = useNav()
|
||||
|
||||
const fixedInclude = ['blog', 'friends', 'blog-archives', 'blog-tags']
|
||||
const fixedInclude = ['blog', 'friends', 'blog-archives', 'blog-tags', 'blog-categories']
|
||||
|
||||
const fixed = computed(() => {
|
||||
return fixedInclude.includes(page.value.type as string)
|
||||
|
||||
@ -35,7 +35,7 @@ const isLocked = useScrollLock(inBrowser ? document.body : null)
|
||||
<style scoped>
|
||||
.vp-nav-screen {
|
||||
position: fixed;
|
||||
top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 1px);
|
||||
top: calc(var(--vp-nav-height) + var(--vp-layout-top-height, 0px));
|
||||
|
||||
/* rtl:ignore */
|
||||
right: 0;
|
||||
|
||||
@ -17,7 +17,7 @@ const { frontmatter, page } = useData()
|
||||
|
||||
const isBlogLayout = computed(() => {
|
||||
const { type } = page.value
|
||||
return type === 'blog' || type === 'blog-archives' || type === 'blog-tags'
|
||||
return type === 'blog' || type === 'blog-archives' || type === 'blog-tags' || type === 'blog-categories'
|
||||
})
|
||||
|
||||
watch([isBlogLayout, () => frontmatter.value.pageLayout], () => nextTick(() =>
|
||||
|
||||
@ -10,11 +10,13 @@ import { useEncrypt } from '../composables/encrypt.js'
|
||||
import { useSidebar } from '../composables/sidebar.js'
|
||||
import { useData } from '../composables/data.js'
|
||||
import { useHeaders } from '../composables/outline.js'
|
||||
import { useBlogPost } from '../composables/page.js'
|
||||
|
||||
const { page, theme, frontmatter, isDark } = useData()
|
||||
const route = useRoute()
|
||||
|
||||
const { hasSidebar, hasAside, leftAside } = useSidebar()
|
||||
const { isBlogPost } = useBlogPost()
|
||||
const headers = useHeaders()
|
||||
const { isPageDecrypted } = useEncrypt()
|
||||
|
||||
@ -66,7 +68,7 @@ watch(
|
||||
:key="page.path" class="vp-doc-container" :class="{
|
||||
'has-sidebar': hasSidebar,
|
||||
'has-aside': enableAside,
|
||||
'is-blog': page.isBlogPost,
|
||||
'is-blog': isBlogPost,
|
||||
'with-encrypt': !isPageDecrypted,
|
||||
}"
|
||||
>
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { useReadingTimeLocale } from '@vuepress/plugin-reading-time/client'
|
||||
import VPLink from '@theme/VPLink.vue'
|
||||
import { useData } from '../composables/data.js'
|
||||
import { useTagColors } from '../composables/tag-colors.js'
|
||||
import { useBlogPost } from '../composables/page.js'
|
||||
import { useBlogExtract } from '../composables/blog-extract.js'
|
||||
|
||||
const { page, frontmatter: matter } = useData<'post'>()
|
||||
|
||||
const { isBlogPost } = useBlogPost()
|
||||
const colors = useTagColors()
|
||||
const readingTime = useReadingTimeLocale()
|
||||
const { categories } = useBlogExtract()
|
||||
|
||||
const createTime = computed(() => {
|
||||
if (matter.value.createTime)
|
||||
@ -36,14 +40,16 @@ const hasMeta = computed(() => readingTime.value.time || tags.value.length || cr
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="page.isBlogPost && categoryList.length"
|
||||
v-if="isBlogPost && categoryList.length"
|
||||
class="vp-doc-category"
|
||||
>
|
||||
<template
|
||||
v-for="({ type, name }, index) in categoryList"
|
||||
:key="`${index}-${type}`"
|
||||
v-for="({ id, name }, index) in categoryList"
|
||||
:key="id"
|
||||
>
|
||||
<span class="category">{{ name }}</span>
|
||||
<VPLink :href="`${categories.link}?id=${id}`" class="category">
|
||||
{{ name }}
|
||||
</VPLink>
|
||||
<span v-if="index !== categoryList.length - 1" class="dot">›</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import VPLocalNavOutlineDropdown from '@theme/VPLocalNavOutlineDropdown.vue'
|
||||
import { useSidebar } from '../composables/sidebar.js'
|
||||
import { useHeaders } from '../composables/outline.js'
|
||||
import { useData } from '../composables/data.js'
|
||||
import { useBlogPost } from '../composables/page.js'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@ -13,7 +14,8 @@ const props = defineProps<{
|
||||
|
||||
defineEmits<(e: 'openMenu') => void>()
|
||||
|
||||
const { page, theme } = useData()
|
||||
const { theme } = useData()
|
||||
const { isBlogPost } = useBlogPost()
|
||||
|
||||
const { hasSidebar } = useSidebar()
|
||||
const { y } = useWindowScroll()
|
||||
@ -39,21 +41,21 @@ const classes = computed(() => {
|
||||
'vp-local-nav': true,
|
||||
'fixed': empty.value,
|
||||
'reached-top': y.value >= navHeight.value,
|
||||
'is-blog': page.value.isBlogPost,
|
||||
'is-blog': isBlogPost,
|
||||
'with-outline': !props.showOutline,
|
||||
}
|
||||
})
|
||||
|
||||
const showLocalNav = computed(() => {
|
||||
return (hasSidebar.value || page.value.isBlogPost) && (!empty.value || y.value >= navHeight.value)
|
||||
return (hasSidebar.value || isBlogPost) && (!empty.value || y.value >= navHeight.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showLocalNav" :class="classes">
|
||||
<button
|
||||
class="menu" :class="{ hidden: page.isBlogPost }"
|
||||
:disabled="page.isBlogPost"
|
||||
class="menu" :class="{ hidden: isBlogPost }"
|
||||
:disabled="isBlogPost"
|
||||
:aria-expanded="open"
|
||||
aria-controls="SidebarNav"
|
||||
@click="$emit('openMenu')"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
|
||||
import { useLocalePostList } from './blog-post-list.js'
|
||||
import { useLocalePostList } from './blog-data.js'
|
||||
|
||||
export type ShortPostItem = Pick<PlumeThemeBlogPostItem, 'title' | 'path' | 'createTime'>
|
||||
|
||||
|
||||
69
theme/src/client/composables/blog-category.ts
Normal file
69
theme/src/client/composables/blog-category.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalePostList } from './blog-data.js'
|
||||
|
||||
export interface BlogCategoryItemWithPost {
|
||||
type: 'post'
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface BlogCategoryItem {
|
||||
id: string
|
||||
type: 'category'
|
||||
sort: number
|
||||
title: string
|
||||
items: (BlogCategoryItem | BlogCategoryItemWithPost)[]
|
||||
}
|
||||
|
||||
export type BlogCategory = (BlogCategoryItem | BlogCategoryItemWithPost)[]
|
||||
|
||||
export function useBlogCategory() {
|
||||
const postList = useLocalePostList()
|
||||
|
||||
const categories = computed(() => {
|
||||
const list: BlogCategory = []
|
||||
|
||||
postList.value.forEach((item) => {
|
||||
const categoryList = item.categoryList
|
||||
if (!categoryList || categoryList.length === 0) {
|
||||
list.push({ type: 'post', title: item.title, path: item.path })
|
||||
}
|
||||
else {
|
||||
let cate = list
|
||||
let i = 0
|
||||
while (i < categoryList.length) {
|
||||
const { id, name, sort } = categoryList[i]
|
||||
const current = cate.find(item => item.type === 'category' && item.id === id)
|
||||
if (!current) {
|
||||
const items = [] as BlogCategoryItem[]
|
||||
cate.push({ type: 'category', title: name, id, sort, items })
|
||||
cate = items
|
||||
}
|
||||
else {
|
||||
cate = (current as BlogCategoryItem).items
|
||||
}
|
||||
i++
|
||||
}
|
||||
cate.push({ type: 'post', title: item.title, path: item.path })
|
||||
}
|
||||
})
|
||||
|
||||
return sortCategory(list)
|
||||
})
|
||||
|
||||
return { categories }
|
||||
}
|
||||
|
||||
function sortCategory(items: BlogCategory): BlogCategory {
|
||||
for (const item of items) {
|
||||
if (item.type === 'category' && item.items.length) {
|
||||
item.items = sortCategory(item.items)
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
if (a.type === 'category' && b.type === 'category') {
|
||||
return a.sort < b.sort ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@ -1,18 +1,24 @@
|
||||
import {
|
||||
blogPostData as blogPostDataRaw,
|
||||
} from '@internal/blogData'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { usePageLang } from 'vuepress/client'
|
||||
import type { PlumeThemeBlogPostData } from '../../shared/index.js'
|
||||
|
||||
export type BlogDataRef = Ref<PlumeThemeBlogPostData>
|
||||
|
||||
export const blogPostData: BlogDataRef = ref(blogPostDataRaw)
|
||||
|
||||
export function useBlogPostData(): BlogDataRef {
|
||||
export function usePostList(): BlogDataRef {
|
||||
return blogPostData as BlogDataRef
|
||||
}
|
||||
|
||||
export function useLocalePostList() {
|
||||
const locale = usePageLang()
|
||||
return computed(() => blogPostData.value.filter(item => item.lang === locale.value))
|
||||
}
|
||||
|
||||
if (__VUEPRESS_DEV__ && (import.meta.webpackHot || import.meta.hot)) {
|
||||
__VUE_HMR_RUNTIME__.updateBlogPostData = (data: PlumeThemeBlogPostData) => {
|
||||
blogPostData.value = data
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useRouteLocale } from 'vuepress/client'
|
||||
import { computed } from 'vue'
|
||||
import type { PresetLocale } from '../../shared/index.js'
|
||||
import { useLocalePostList } from './blog-post-list.js'
|
||||
import { useLocalePostList } from './blog-data.js'
|
||||
import { useTags } from './blog-tags.js'
|
||||
import { type BlogCategory, useBlogCategory } from './blog-category.js'
|
||||
import { useData } from './data.js'
|
||||
import { useLocaleLink } from './locale.js'
|
||||
|
||||
@ -10,16 +11,29 @@ declare const __PLUME_PRESET_LOCALE__: Record<string, PresetLocale>
|
||||
|
||||
const presetLocales = __PLUME_PRESET_LOCALE__
|
||||
|
||||
export function useBlogNavTitle(name: keyof PresetLocale) {
|
||||
const locale = useRouteLocale()
|
||||
|
||||
return computed(() => presetLocales[locale.value]?.[name] || presetLocales['/'][name])
|
||||
}
|
||||
|
||||
export function useBlogExtract() {
|
||||
const { theme } = useData()
|
||||
const locale = useRouteLocale()
|
||||
const postList = useLocalePostList()
|
||||
const { tags: tagsList } = useTags()
|
||||
const { categories: categoryList } = useBlogCategory()
|
||||
const blog = computed(() => theme.value.blog || {})
|
||||
|
||||
const hasBlogExtract = computed(() => blog.value.archives !== false || blog.value.tags !== false)
|
||||
const hasBlogExtract = computed(() =>
|
||||
blog.value.archives !== false
|
||||
|| blog.value.tags !== false
|
||||
|| blog.value.categories !== false,
|
||||
)
|
||||
|
||||
const tagsLink = useLocaleLink(blog.value.tagsLink || 'blog/tags/')
|
||||
const archiveLink = useLocaleLink(blog.value.archivesLink || 'blog/archives/')
|
||||
const categoriesLink = useLocaleLink(blog.value.categoriesLink || 'blog/categories/')
|
||||
|
||||
const tags = computed(() => ({
|
||||
link: tagsLink.value,
|
||||
@ -33,9 +47,29 @@ export function useBlogExtract() {
|
||||
total: postList.value.length,
|
||||
}))
|
||||
|
||||
const categories = computed(() => ({
|
||||
link: categoriesLink.value,
|
||||
text: presetLocales[locale.value]?.category || presetLocales['/'].category,
|
||||
total: getCategoriesTotal(categoryList.value),
|
||||
}))
|
||||
|
||||
return {
|
||||
hasBlogExtract,
|
||||
tags,
|
||||
archives,
|
||||
categories,
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoriesTotal(categories: BlogCategory): number {
|
||||
let total = 0
|
||||
for (const category of categories) {
|
||||
if (category.type === 'category') {
|
||||
total += 1
|
||||
if (category.items.length) {
|
||||
total += getCategoriesTotal(category.items)
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@ -1,19 +1,12 @@
|
||||
import { usePageLang } from 'vuepress/client'
|
||||
import { computed } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
|
||||
import { useBlogPostData } from './blog-data.js'
|
||||
import { useLocalePostList } from './blog-data.js'
|
||||
import { useData } from './data.js'
|
||||
import { useRouteQuery } from './route-query.js'
|
||||
|
||||
const DEFAULT_PER_PAGE = 10
|
||||
|
||||
export function useLocalePostList() {
|
||||
const locale = usePageLang()
|
||||
const list = useBlogPostData()
|
||||
return computed(() => list.value.filter(item => item.lang === locale.value))
|
||||
}
|
||||
|
||||
export function usePostListControl() {
|
||||
const { theme } = useData()
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { computed } from 'vue'
|
||||
import type { PlumeThemeBlogPostItem } from '../../shared/index.js'
|
||||
import { toArray } from '../utils/index.js'
|
||||
import { useTagColors } from './tag-colors.js'
|
||||
import { useLocalePostList } from './blog-post-list.js'
|
||||
import { useLocalePostList } from './blog-data.js'
|
||||
import { useRouteQuery } from './route-query.js'
|
||||
|
||||
type ShortPostItem = Pick<PlumeThemeBlogPostItem, 'title' | 'path' | 'createTime'>
|
||||
|
||||
@ -18,7 +18,9 @@ export * from './blog-post-list.js'
|
||||
export * from './blog-extract.js'
|
||||
export * from './blog-tags.js'
|
||||
export * from './blog-archives.js'
|
||||
export * from './blog-category.js'
|
||||
export * from './tag-colors.js'
|
||||
export * from './page.js'
|
||||
|
||||
export * from './encrypt-data.js'
|
||||
export * from './encrypt.js'
|
||||
|
||||
@ -4,6 +4,7 @@ import { normalizeLink } from '../utils/index.js'
|
||||
import { useThemeData } from './theme-data.js'
|
||||
import { useData } from './data.js'
|
||||
import { getSidebarFirstLink, useSidebarData } from './sidebar.js'
|
||||
import { useBlogPost } from './page.js'
|
||||
|
||||
export function useLangs({
|
||||
removeCurrent = true,
|
||||
@ -12,6 +13,7 @@ export function useLangs({
|
||||
const { page } = useData()
|
||||
const routeLocale = useRouteLocale()
|
||||
const sidebar = useSidebarData()
|
||||
const { isBlogPost } = useBlogPost()
|
||||
|
||||
const currentLang = computed(() => {
|
||||
const link = routeLocale.value
|
||||
@ -28,7 +30,7 @@ export function useLangs({
|
||||
if (!notFound)
|
||||
return path
|
||||
const blog = theme.value.blog
|
||||
if (page.value.isBlogPost)
|
||||
if (isBlogPost.value)
|
||||
return withBase(blog?.link || normalizeLink(locale, 'blog/'))
|
||||
|
||||
const sidebarList = sidebar.value
|
||||
|
||||
16
theme/src/client/composables/page.ts
Normal file
16
theme/src/client/composables/page.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { computed } from 'vue'
|
||||
import { useData } from './data.js'
|
||||
import { usePostList } from './blog-data.js'
|
||||
|
||||
export function useBlogPost() {
|
||||
const { page } = useData()
|
||||
const postList = usePostList()
|
||||
|
||||
const isBlogPost = computed(() => {
|
||||
return postList.value.some(item => item.path === page.value.path)
|
||||
})
|
||||
|
||||
return {
|
||||
isBlogPost,
|
||||
}
|
||||
}
|
||||
@ -4,23 +4,25 @@ import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { NavItemWithLink, PlumeThemeBlogPostItem, SidebarItem } from '../../shared/index.js'
|
||||
import { resolveNavLink } from '../utils/index.js'
|
||||
import { useBlogPostData } from './blog-data.js'
|
||||
import { usePostList } from './blog-data.js'
|
||||
import { useSidebar } from './sidebar.js'
|
||||
import { useData } from './data.js'
|
||||
import { useBlogPost } from './page.js'
|
||||
|
||||
export function usePrevNext() {
|
||||
const route = useRoute()
|
||||
const { page, frontmatter } = useData()
|
||||
const { frontmatter } = useData()
|
||||
const { sidebar } = useSidebar()
|
||||
const postList = useBlogPostData() as unknown as Ref<PlumeThemeBlogPostItem[]>
|
||||
const postList = usePostList() as unknown as Ref<PlumeThemeBlogPostItem[]>
|
||||
const locale = usePageLang()
|
||||
const { isBlogPost } = useBlogPost()
|
||||
|
||||
const prevNavList = computed(() => {
|
||||
const prevConfig = resolveFromFrontmatterConfig(frontmatter.value.prev)
|
||||
if (prevConfig !== false)
|
||||
return prevConfig
|
||||
|
||||
if (page.value.isBlogPost) {
|
||||
if (isBlogPost.value) {
|
||||
return resolveFromBlogPostData(
|
||||
postList.value.filter(item => item.lang === locale.value),
|
||||
route.path,
|
||||
@ -37,7 +39,7 @@ export function usePrevNext() {
|
||||
if (nextConfig !== false)
|
||||
return nextConfig
|
||||
|
||||
if (page.value.isBlogPost) {
|
||||
if (isBlogPost.value) {
|
||||
return resolveFromBlogPostData(
|
||||
postList.value.filter(item => item.lang === locale.value),
|
||||
route.path,
|
||||
|
||||
@ -121,6 +121,10 @@
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='currentColor' d='m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h36a4 4 0 0 0 4-4v-20h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-16h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-16h-7.73a8.17 8.17 0 0 1-8.27-7.47a8 8 0 0 1 8-8.53h8v-7.73a8.18 8.18 0 0 1 7.47-8.25a8 8 0 0 1 8.53 8v8h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v16h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v16h7.73a8.17 8.17 0 0 1 8.25 7.47a8 8 0 0 1-8 8.53h-8v20a4 4 0 0 0 4 4h84a16 16 0 0 0 16-16V88a8 8 0 0 0-2.28-5.66M152 88V44l44 44Z' /%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vpi-category {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 20 20'%3E%3Cpath fill='%23000' d='M5 7h13v10H2V4h7l2 2H4v9h1z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vpi-blog-ext {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='currentColor' d='M22 3H5a2 2 0 0 0-2 2v4h2V5h17v14H5v-4H3v4a2 2 0 0 0 2 2h17a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2M7 15v-2H0v-2h7V9l4 3zm13-2h-7v-2h7zm0-4h-7V7h7zm-3 8h-4v-2h4z' /%3E%3C/svg%3E");
|
||||
}
|
||||
@ -136,3 +140,15 @@
|
||||
.vpi-back-to-top {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' d='M24.008 14.1V42M12 26l12-12l12 12M12 6h24' /%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vpi-folder {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 1024 1024'%3E%3Cpath fill='%23000' d='M880 298.4H521L403.7 186.2a8.15 8.15 0 0 0-5.5-2.2H144c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V330.4c0-17.7-14.3-32-32-32'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vpi-folder-open {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 1024 1024'%3E%3Cpath fill='%23000' d='M928 444H820V330.4c0-17.7-14.3-32-32-32H473L355.7 186.2a8.15 8.15 0 0 0-5.5-2.2H96c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h698c13 0 24.8-7.9 29.7-20l134-332c1.5-3.8 2.3-7.9 2.3-12c0-17.7-14.3-32-32-32m-180 0H238c-13 0-24.8 7.9-29.7 20L136 643.2V256h188.5l119.6 114.4H748z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vpi-post {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M20 22H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1m-1-2V4H5v16zM7 6h4v4H7zm0 6h10v2H7zm0 4h10v2H7zm6-9h4v2h-4z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ export const enPresetLocale: PresetLocale = {
|
||||
blog: 'Blog',
|
||||
tag: 'Tags',
|
||||
archive: 'Archives',
|
||||
category: 'Categories',
|
||||
}
|
||||
|
||||
export const enSearchLocale: Partial<SearchLocaleOptions> = {
|
||||
|
||||
@ -44,6 +44,7 @@ export const zhPresetLocale: PresetLocale = {
|
||||
blog: '博客',
|
||||
tag: '标签',
|
||||
archive: '归档',
|
||||
category: '分类',
|
||||
}
|
||||
|
||||
export const zhDocsearchLocale: DocsearchLocaleOptions = {
|
||||
|
||||
@ -2,19 +2,17 @@ import {
|
||||
ensureLeadingSlash,
|
||||
getRootLang,
|
||||
getRootLangPath,
|
||||
removeLeadingSlash,
|
||||
} from '@vuepress/helper'
|
||||
import type { App, Page } from 'vuepress/core'
|
||||
import { createPage } from 'vuepress/core'
|
||||
import { createFilter } from 'create-filter'
|
||||
import type {
|
||||
PageCategoryData,
|
||||
PlumeThemeLocaleOptions,
|
||||
PlumeThemePageData,
|
||||
} from '../shared/index.js'
|
||||
import { normalizePath, withBase } from './utils/index.js'
|
||||
import { hash, withBase } from './utils/index.js'
|
||||
import { PRESET_LOCALES } from './locales/index.js'
|
||||
import { resolveNotesLinkList, resolveNotesOptions } from './config/index.js'
|
||||
import { resolveNotesLinkList } from './config/index.js'
|
||||
|
||||
export async function setupPage(
|
||||
app: App,
|
||||
@ -54,39 +52,17 @@ export async function setupPage(
|
||||
path: withBase(blog.archivesLink || `${link}/archives/`, localePath),
|
||||
frontmatter: { lang, _pageLayout: 'blog-archives', title: getTitle(locale, 'archive') },
|
||||
}))
|
||||
|
||||
// 添加分类页
|
||||
blog.categories !== false && pageList.push(createPage(app, {
|
||||
path: withBase(blog.categoriesLink || `${link}/categories/`, localePath),
|
||||
frontmatter: { lang, _pageLayout: 'blog-categories', title: getTitle(locale, 'category') },
|
||||
}))
|
||||
}
|
||||
|
||||
app.pages.push(...await Promise.all(pageList))
|
||||
}
|
||||
|
||||
const weakFilter = new WeakMap<PlumeThemeLocaleOptions, (id: string | undefined) => boolean>()
|
||||
|
||||
function createBlogFilter(localeOptions: PlumeThemeLocaleOptions) {
|
||||
if (weakFilter.has(localeOptions))
|
||||
return weakFilter.get(localeOptions)!
|
||||
|
||||
const blog = localeOptions.blog || {}
|
||||
const notesList = resolveNotesOptions(localeOptions)
|
||||
const notesDirList = notesList
|
||||
.map(notes => removeLeadingSlash(normalizePath(`${notes.dir}/**`)))
|
||||
.filter(Boolean)
|
||||
|
||||
const filter = createFilter(
|
||||
blog.include ?? ['**/*.md'],
|
||||
[
|
||||
'**/{README,readme,index}.md',
|
||||
'.vuepress/',
|
||||
'node_modules/',
|
||||
...(blog.exclude ?? []),
|
||||
...notesDirList,
|
||||
].filter(Boolean),
|
||||
{ resolve: false },
|
||||
)
|
||||
|
||||
weakFilter.set(localeOptions, filter)
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
export function extendsPageData(
|
||||
page: Page<PlumeThemePageData>,
|
||||
localeOptions: PlumeThemeLocaleOptions,
|
||||
@ -94,10 +70,6 @@ export function extendsPageData(
|
||||
page.data.filePathRelative = page.filePathRelative
|
||||
page.routeMeta.title = page.frontmatter.title || page.title
|
||||
|
||||
if (createBlogFilter(localeOptions)(page.filePathRelative || '')) {
|
||||
page.data.isBlogPost = true
|
||||
}
|
||||
|
||||
if (page.frontmatter.icon) {
|
||||
page.routeMeta.icon = page.frontmatter.icon
|
||||
}
|
||||
@ -133,7 +105,6 @@ export function extendsPageData(
|
||||
}
|
||||
|
||||
autoCategory(page, localeOptions)
|
||||
pageContentRendered(page)
|
||||
}
|
||||
|
||||
let uuid = 10000
|
||||
@ -157,26 +128,21 @@ export function autoCategory(
|
||||
LOCALE_RE ??= new RegExp(
|
||||
`^(${Object.keys(options.locales || {}).filter(l => l !== '/').join('|')})`,
|
||||
)
|
||||
const categoryList: PageCategoryData[] = ensureLeadingSlash(pagePath)
|
||||
const list = ensureLeadingSlash(pagePath)
|
||||
.replace(LOCALE_RE, '')
|
||||
.replace(/^\//, '')
|
||||
.split('/')
|
||||
.slice(0, -1)
|
||||
.map((category) => {
|
||||
|
||||
const categoryList: PageCategoryData[] = list
|
||||
.map((category, index) => {
|
||||
const match = category.match(RE_CATEGORY) || []
|
||||
!cache[match[2]] && !match[1] && (cache[match[2]] = uuid++)
|
||||
return {
|
||||
type: Number(match[1] || cache[match[2]]),
|
||||
id: hash(list.slice(0, index + 1).join('-')).slice(0, 6),
|
||||
sort: Number(match[1] || cache[match[2]]),
|
||||
name: match[2],
|
||||
}
|
||||
})
|
||||
page.data.categoryList = categoryList
|
||||
}
|
||||
|
||||
export function pageContentRendered(page: Page<PlumeThemePageData>) {
|
||||
const EXCERPT_SPLIT = '<!-- more -->'
|
||||
if (page.data.isBlogPost && page.contentRendered.includes(EXCERPT_SPLIT)) {
|
||||
const [excerpt, content] = page.contentRendered.split(EXCERPT_SPLIT)
|
||||
page.contentRendered = `<div class="excerpt">${excerpt}</div>${EXCERPT_SPLIT}${content}`
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ export interface PresetLocale {
|
||||
blog: string
|
||||
tag: string
|
||||
archive: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface ThemeTransition {
|
||||
|
||||
@ -79,4 +79,17 @@ export interface PlumeThemeBlog {
|
||||
* @default '/blog/archives/'
|
||||
*/
|
||||
archivesLink?: string
|
||||
|
||||
/**
|
||||
* 是否启用分类页
|
||||
* @default true
|
||||
*/
|
||||
categories?: boolean
|
||||
|
||||
/**
|
||||
* 自定义分类页链接
|
||||
*
|
||||
* @default '/blog/categories/'
|
||||
*/
|
||||
categoriesLink?: string
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ export interface PlumeThemePageData extends GitPluginPageData {
|
||||
}
|
||||
|
||||
export interface PageCategoryData {
|
||||
type: string | number
|
||||
id: string
|
||||
sort: number
|
||||
name: string
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user