@velonor/theme 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +11 -0
- package/package.json +67 -0
- package/src/archive.data.ts +4 -0
- package/src/categories.data.ts +4 -0
- package/src/components/Archive.vue +121 -0
- package/src/components/Blog.vue +232 -0
- package/src/components/Categories.vue +84 -0
- package/src/components/Comment.vue +25 -0
- package/src/components/Tags.vue +105 -0
- package/src/components/ThemeLayout.vue +215 -0
- package/src/components/UpdateHeatmap.vue +128 -0
- package/src/components/UserCard.vue +61 -0
- package/src/components/WidgetCard.vue +26 -0
- package/src/components/icon/IconLink.vue +16 -0
- package/src/components/icon/IconMore.vue +16 -0
- package/src/composables/useCategories.ts +6 -0
- package/src/composables/useRefreshOnRouteChange.ts +20 -0
- package/src/composables/useTagFilter.ts +120 -0
- package/src/composables/useTags.ts +6 -0
- package/src/defaultconfig.mjs +22 -0
- package/src/env.d.ts +7 -0
- package/src/genFeed.mjs +44 -0
- package/src/index.ts +22 -0
- package/src/layouts/Layout.vue +407 -0
- package/src/page-links.data.ts +4 -0
- package/src/posts.data.js +5 -0
- package/src/style.css +52 -0
- package/src/tags.data.ts +4 -0
- package/src/types/config.d.ts +8 -0
- package/src/types/index.d.ts +2 -0
- package/src/types/theme.d.ts +74 -0
- package/src/utils/constants.ts +2 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ThemeLayout>
|
|
3
|
+
<div
|
|
4
|
+
class="flex w-full flex-col bg-opacity-85 backdrop-blur-md dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)] rounded-2xl py-10 px-6 md:px-10 gap-8">
|
|
5
|
+
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
|
6
|
+
<div class="flex flex-col gap-2">
|
|
7
|
+
<div class="text-3xl md:text-4xl font-bold tracking-tight">{{ categoriesText }}</div>
|
|
8
|
+
<div class="text-sm opacity-70">
|
|
9
|
+
{{ totalPosts }} {{ postsText }} {{ uniqueCategoryCount }} {{ categoriesText }}
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="flex items-center gap-2 text-xs">
|
|
13
|
+
<span class="px-3 py-1 rounded-full border border-[var(--blog-border-c)] bg-[var(--vp-c-bg)]/60">
|
|
14
|
+
{{ ActiveCategory ? ActiveCategory : allText }}
|
|
15
|
+
</span>
|
|
16
|
+
<a v-if="blogHomeLink" :href="withBase(blogHomeLink)"
|
|
17
|
+
class="px-3 py-1 rounded-full border border-[var(--blog-border-c)]/70 bg-[var(--vp-c-bg)]/40 hover:bg-[var(--vp-c-bg)]/60 transition">
|
|
18
|
+
{{ backToBlogText }}
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- categories list -->
|
|
24
|
+
<div class="flex justify-left items-center flex-wrap md:mx-2 md:gap-3 gap-2">
|
|
25
|
+
<button v-for="categoryItem in categoryArray" :key="categoryItem[0]"
|
|
26
|
+
class="px-3 py-1 rounded-full cursor-pointer border text-sm transition duration-150 ease-out hover:-translate-y-0.5 hover:shadow-sm"
|
|
27
|
+
:class="{
|
|
28
|
+
'bg-[var(--blog-tag-bg-2)] text-[var(--blog-tag-text-2)] border-[var(--blog-tag-text-2)]': ActiveCategory == categoryItem[0],
|
|
29
|
+
'bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]': ActiveCategory != categoryItem[0]
|
|
30
|
+
}" @click="ActiveCategory = categoryItem[0]">
|
|
31
|
+
<span>{{ categoryItem[0] == '' ? allText : categoryItem[0] }}</span>
|
|
32
|
+
<span class="ml-2 opacity-70"> {{ categoryItem[1] }}</span>
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="w-full border-dashed border-t-2 border-[var(--blog-border-c)]"></div>
|
|
37
|
+
|
|
38
|
+
<!-- posts with categories -->
|
|
39
|
+
<div class="flex justify-center flex-col gap-6 md:gap-5">
|
|
40
|
+
<div
|
|
41
|
+
class="flex items-center gap-3 flex-col md:flex-row md:gap-12 md:justify-between rounded-xl px-4 py-3 border border-transparent hover:border-[var(--blog-border-c)] hover:bg-[var(--vp-c-bg)]/40 transition"
|
|
42
|
+
v-for="post in filteredList" :key="post.url">
|
|
43
|
+
<a :href="withBase(post.url)" class="hover:underline text-base font-medium">
|
|
44
|
+
{{ post.frontmatter.title }}
|
|
45
|
+
</a>
|
|
46
|
+
<div class="flex justify-end items-end gap-2 flex-wrap">
|
|
47
|
+
<span class="text-xs px-2 py-0.5 rounded-full border bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]"
|
|
48
|
+
v-for="(tag, idx) in post.frontmatter.tags" :key="`${post.url}-tag-${idx}`">
|
|
49
|
+
{{ tag }}
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</ThemeLayout>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<script setup>
|
|
59
|
+
import { computed } from 'vue';
|
|
60
|
+
import { withBase, useData } from 'vitepress';
|
|
61
|
+
import ThemeLayout from './ThemeLayout.vue';
|
|
62
|
+
import { getLocalizedString } from '@velonor/engine';
|
|
63
|
+
import { useCategories } from '../composables/useCategories';
|
|
64
|
+
import { data as pageLinks } from '../page-links.data';
|
|
65
|
+
import { data as categoriesIndex } from '../categories.data';
|
|
66
|
+
|
|
67
|
+
const { lang, theme } = useData();
|
|
68
|
+
const allText = computed(() => getLocalizedString('all', lang.value));
|
|
69
|
+
const categoriesText = computed(() => getLocalizedString('category', lang.value));
|
|
70
|
+
const postsText = computed(() => getLocalizedString('posts', lang.value));
|
|
71
|
+
const backToBlogText = computed(() => getLocalizedString('backToBlog', lang.value));
|
|
72
|
+
const blogHomeLink = computed(() => theme.value.blog?.homePageLink || pageLinks?.blog || '/page/blog');
|
|
73
|
+
|
|
74
|
+
const { activeCategory: ActiveCategory, getCategoryArray, filterPostsByActiveCategory, uniqueCategoryCount: uniqueCategoryCountRaw, categoriesMap } = useCategories({
|
|
75
|
+
otherLabel: getLocalizedString('other', lang.value),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const categoryArray = computed(() => categoriesIndex.categoryArray || getCategoryArray());
|
|
79
|
+
const totalPosts = computed(() => categoriesIndex.totalPosts ?? (categoriesMap.value[''] || 0));
|
|
80
|
+
const uniqueCategoryCount = computed(() => categoriesIndex.uniqueCategoryCount ?? uniqueCategoryCountRaw.value);
|
|
81
|
+
|
|
82
|
+
const filteredList = computed(() => filterPostsByActiveCategory());
|
|
83
|
+
</script>
|
|
84
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- giscus评论 -->
|
|
3
|
+
<div class="comment mt-5 opacity-80" v-if="useComment && refreshFlag">
|
|
4
|
+
<Giscus v-if="CommentConfig" :repo="CommentConfig.repo" :repo-id="CommentConfig.repoId"
|
|
5
|
+
:category="CommentConfig.category" :category-id="CommentConfig.categoryId" :mapping="CommentConfig.mapping"
|
|
6
|
+
:strict="CommentConfig.strict" :reactions-enabled="CommentConfig.reactionsEnabled"
|
|
7
|
+
:emit-metadata="CommentConfig.emitMetadata" :input-position="CommentConfig.inputPosition"
|
|
8
|
+
:lang="CommentConfig.lang" :theme="commentTheme" />
|
|
9
|
+
</div>
|
|
10
|
+
</template>
|
|
11
|
+
|
|
12
|
+
<script lang="ts" setup>
|
|
13
|
+
import Giscus from '@giscus/vue';
|
|
14
|
+
import { useData } from 'vitepress';
|
|
15
|
+
import { useRefreshOnRouteChange } from '../composables/useRefreshOnRouteChange';
|
|
16
|
+
import { computed } from 'vue';
|
|
17
|
+
const { frontmatter, isDark, theme } = useData<Open17Config>();
|
|
18
|
+
const CommentConfig = theme.value.comment;
|
|
19
|
+
const { refreshFlag } = useRefreshOnRouteChange();
|
|
20
|
+
const useComment = computed(() => {
|
|
21
|
+
if (frontmatter.value.comment === false) return false;
|
|
22
|
+
return theme.value.comment?.use || frontmatter.value.comment !== undefined;
|
|
23
|
+
});
|
|
24
|
+
const commentTheme = computed(() => (isDark.value ? 'dark_tritanopia' : 'light_tritanopia'));
|
|
25
|
+
</script>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ThemeLayout>
|
|
3
|
+
<div
|
|
4
|
+
class="flex w-full flex-col bg-opacity-85 backdrop-blur-md dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)] rounded-2xl py-10 px-6 md:px-10 gap-8">
|
|
5
|
+
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
|
6
|
+
<div class="flex flex-col gap-2">
|
|
7
|
+
<div class="text-3xl md:text-4xl font-bold tracking-tight">{{ tagsText }}</div>
|
|
8
|
+
<div class="text-sm opacity-70">
|
|
9
|
+
{{ totalPosts }} {{ postsText }} {{ uniqueTagCount }} {{ tagsText }}
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="flex items-center gap-2 text-xs">
|
|
13
|
+
<span class="px-3 py-1 rounded-full border border-[var(--blog-border-c)] bg-[var(--vp-c-bg)]/60">
|
|
14
|
+
{{ activeLabel }}
|
|
15
|
+
</span>
|
|
16
|
+
<a v-if="blogHomeLink" :href="withBase(blogHomeLink)"
|
|
17
|
+
class="px-3 py-1 rounded-full border border-[var(--blog-border-c)]/70 bg-[var(--vp-c-bg)]/40 hover:bg-[var(--vp-c-bg)]/60 transition">
|
|
18
|
+
{{ backToBlogText }}
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<!-- tags list -->
|
|
24
|
+
<div class="flex justify-left items-center flex-wrap md:mx-2 md:gap-3 gap-2">
|
|
25
|
+
<button v-for="tagItem in tagArray" :key="tagItem[0]"
|
|
26
|
+
class="px-3 py-1 rounded-full cursor-pointer border text-sm transition duration-150 ease-out hover:-translate-y-0.5 hover:shadow-sm"
|
|
27
|
+
:class="{
|
|
28
|
+
'bg-[var(--blog-tag-bg-2)] text-[var(--blog-tag-text-2)] border-[var(--blog-tag-text-2)]': isTagSelected(tagItem[0]),
|
|
29
|
+
'bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]': !isTagSelected(tagItem[0])
|
|
30
|
+
}" @click="toggleTag(tagItem[0])">
|
|
31
|
+
<span>{{ tagItem[0] == '' ? allText : tagItem[0] }}</span>
|
|
32
|
+
<span class="ml-2 opacity-70"> {{ tagItem[1] }}</span>
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="w-full border-dashed border-t-2 border-[var(--blog-border-c)]"></div>
|
|
37
|
+
|
|
38
|
+
<!-- posts with tags -->
|
|
39
|
+
<div class="flex justify-center flex-col gap-6 md:gap-5">
|
|
40
|
+
<div
|
|
41
|
+
class="flex items-center gap-3 flex-col md:flex-row md:gap-12 md:justify-between rounded-xl px-4 py-3 border border-transparent hover:border-[var(--blog-border-c)] hover:bg-[var(--vp-c-bg)]/40 transition"
|
|
42
|
+
v-for="post in filteredList" :key="post.url">
|
|
43
|
+
<a :href="withBase(post.url)" class="hover:underline text-base font-medium">
|
|
44
|
+
{{ post.frontmatter.title }}
|
|
45
|
+
</a>
|
|
46
|
+
<div class="flex justify-end items-end gap-2 flex-wrap">
|
|
47
|
+
<span class="cursor-pointer text-xs px-2 py-0.5 rounded-full border" :class="{
|
|
48
|
+
'bg-[var(--blog-tag-bg-2)] text-[var(--blog-tag-text-2)] border-[var(--blog-tag-text-2)]': isTagSelected(tag),
|
|
49
|
+
'bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]': !isTagSelected(tag)
|
|
50
|
+
}" v-for="(tag, idx) in post.frontmatter.tags" :key="`${post.url}-tag-${idx}`"
|
|
51
|
+
@click="toggleTag(tag)">
|
|
52
|
+
{{ tag }}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</ThemeLayout>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script setup>
|
|
62
|
+
import { computed } from 'vue';
|
|
63
|
+
import { withBase, useData } from 'vitepress';
|
|
64
|
+
import ThemeLayout from './ThemeLayout.vue';
|
|
65
|
+
import { getLocalizedString } from '@velonor/engine';
|
|
66
|
+
import { useTagFilter } from '../composables/useTagFilter';
|
|
67
|
+
import { data as pageLinks } from '../page-links.data';
|
|
68
|
+
import { data as tagIndex } from '../tags.data';
|
|
69
|
+
|
|
70
|
+
const { lang, theme } = useData();
|
|
71
|
+
const allText = computed(() => getLocalizedString('all', lang.value));
|
|
72
|
+
const tagsText = computed(() => getLocalizedString('tags', lang.value));
|
|
73
|
+
const postsText = computed(() => getLocalizedString('posts', lang.value));
|
|
74
|
+
const backToBlogText = computed(() => getLocalizedString('backToBlog', lang.value));
|
|
75
|
+
const blogHomeLink = computed(() => theme.value.blog?.homePageLink || pageLinks?.blog || '/page/blog');
|
|
76
|
+
|
|
77
|
+
const {
|
|
78
|
+
selectedTags,
|
|
79
|
+
getTagArray,
|
|
80
|
+
uniqueTagCount: uniqueTagCountRaw,
|
|
81
|
+
tagsMap,
|
|
82
|
+
filterPostsByActiveTag,
|
|
83
|
+
toggleTag,
|
|
84
|
+
isTagSelected,
|
|
85
|
+
} = useTagFilter();
|
|
86
|
+
|
|
87
|
+
const tagArray = computed(() => tagIndex.tagArray || getTagArray());
|
|
88
|
+
const totalPosts = computed(() => tagIndex.totalPosts ?? (tagsMap.value[''] || 0));
|
|
89
|
+
const uniqueTagCount = computed(() => tagIndex.uniqueTagCount ?? uniqueTagCountRaw.value);
|
|
90
|
+
|
|
91
|
+
const activeLabel = computed(() => {
|
|
92
|
+
if (!selectedTags.value.length) return allText.value;
|
|
93
|
+
return selectedTags.value.join(' / ');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const filteredList = computed(() => {
|
|
97
|
+
const allPosts = filterPostsByActiveTag('');
|
|
98
|
+
if (!selectedTags.value.length) return allPosts;
|
|
99
|
+
return allPosts.filter((item) => {
|
|
100
|
+
const tags = item.frontmatter.tags || [];
|
|
101
|
+
return selectedTags.value.some((tag) => tags.includes(tag));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
105
|
+
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useData } from 'vitepress';
|
|
3
|
+
import { computed, watch, ref } from 'vue';
|
|
4
|
+
import UserCard from './UserCard.vue';
|
|
5
|
+
import WidgetCard from './WidgetCard.vue';
|
|
6
|
+
import UpdateHeatmap from './UpdateHeatmap.vue';
|
|
7
|
+
import { getLocalizedString } from '@velonor/engine';
|
|
8
|
+
import { useTagFilter } from '../composables/useTagFilter';
|
|
9
|
+
import { useCategories } from '../composables/useCategories';
|
|
10
|
+
import { data as pageLinks } from '../page-links.data';
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
13
|
+
const props = defineProps({
|
|
14
|
+
showContent: Boolean,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { theme, lang, frontmatter } = useData();
|
|
18
|
+
|
|
19
|
+
const blogConfig = computed(() => theme.value.blog || {});
|
|
20
|
+
const direct = computed(() => blogConfig.value.direct || 'lft');
|
|
21
|
+
const isBlogHome = computed(() => frontmatter.value?.layout === 'blog');
|
|
22
|
+
const resolvedTagPageLink = computed(
|
|
23
|
+
() => blogConfig.value.tagPageLink || pageLinks?.tags || ''
|
|
24
|
+
);
|
|
25
|
+
const resolvedArchivePageLink = computed(
|
|
26
|
+
() => blogConfig.value.archivePageLink || pageLinks?.archive || ''
|
|
27
|
+
);
|
|
28
|
+
const resolvedCategoryPageLink = computed(
|
|
29
|
+
() => blogConfig.value.categoryPageLink || pageLinks?.categories || ''
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
selectedTags,
|
|
34
|
+
getTagArray,
|
|
35
|
+
addTag,
|
|
36
|
+
removeTag,
|
|
37
|
+
setSelectedTags,
|
|
38
|
+
} = useTagFilter();
|
|
39
|
+
const { activeCategory, getCategoryArray, filterPostsByActiveCategory } = useCategories({
|
|
40
|
+
otherLabel: getLocalizedString('other', lang.value),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const allText = computed(() => getLocalizedString('all', lang.value));
|
|
44
|
+
|
|
45
|
+
const tagQuery = ref('');
|
|
46
|
+
|
|
47
|
+
const tagSuggestions = computed(() => {
|
|
48
|
+
const query = tagQuery.value.trim().toLowerCase();
|
|
49
|
+
if (!query) return [];
|
|
50
|
+
return getTagArray()
|
|
51
|
+
.filter(([name]) => name && name.toLowerCase().includes(query))
|
|
52
|
+
.filter(([name]) => !selectedTags.value.includes(name))
|
|
53
|
+
.slice(0, 12);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const displayedTags = computed(() => {
|
|
57
|
+
if (tagQuery.value.trim()) return tagSuggestions.value;
|
|
58
|
+
return [];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const addTagFromQuery = () => {
|
|
62
|
+
const query = tagQuery.value.trim();
|
|
63
|
+
if (!query) return;
|
|
64
|
+
const lower = query.toLowerCase();
|
|
65
|
+
const match = getTagArray().find(([name]) => name.toLowerCase() === lower);
|
|
66
|
+
const target = match?.[0] || tagSuggestions.value[0]?.[0];
|
|
67
|
+
if (!target) return;
|
|
68
|
+
addTag(target);
|
|
69
|
+
tagQuery.value = '';
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
watch(
|
|
73
|
+
activeCategory,
|
|
74
|
+
(newCat) => {
|
|
75
|
+
if (!selectedTags.value.length) return;
|
|
76
|
+
const postsInCategory = filterPostsByActiveCategory(newCat);
|
|
77
|
+
const allowed = new Set();
|
|
78
|
+
postsInCategory.forEach((post) => {
|
|
79
|
+
const tags = post.frontmatter.tags || [];
|
|
80
|
+
tags.forEach((tag) => allowed.add(tag));
|
|
81
|
+
});
|
|
82
|
+
const nextTags = selectedTags.value.filter((tag) => allowed.has(tag));
|
|
83
|
+
if (nextTags.length !== selectedTags.value.length) {
|
|
84
|
+
setSelectedTags(nextTags);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{ immediate: true }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
watch(
|
|
92
|
+
selectedTags,
|
|
93
|
+
(tags) => {
|
|
94
|
+
if (!activeCategory.value) return;
|
|
95
|
+
if (!tags.length) return;
|
|
96
|
+
const postsInCategory = filterPostsByActiveCategory(activeCategory.value);
|
|
97
|
+
const tagExistsInCategory = tags.some((tag) =>
|
|
98
|
+
postsInCategory.some((p) => p.frontmatter.tags && p.frontmatter.tags.includes(tag))
|
|
99
|
+
);
|
|
100
|
+
if (!tagExistsInCategory) {
|
|
101
|
+
activeCategory.value = '';
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
{ immediate: true }
|
|
105
|
+
);
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<template>
|
|
109
|
+
<div class="w-full flex justify-center">
|
|
110
|
+
<div class="flex w-full max-w-screen-2xl justify-center items-start pt-0 my-0 gap-5 md:px-20 flex-col-reverse"
|
|
111
|
+
:class="{
|
|
112
|
+
'md:flex-row': direct == 'lft',
|
|
113
|
+
'md:flex-row-reverse': direct == 'rgt',
|
|
114
|
+
}">
|
|
115
|
+
<!-- 博客侧边栏 -->
|
|
116
|
+
<div class="flex bg-transparent w-full md:w-[23%] md:max-w-[23%] md:flex-none md:shrink-0 min-w-0 justify-center items-start pt-16 pb-12 flex-col gap-5"
|
|
117
|
+
v-if="!blogConfig.pureMode">
|
|
118
|
+
<!-- 电脑端个人信息 -->
|
|
119
|
+
<UserCard :isMobile="false" />
|
|
120
|
+
|
|
121
|
+
<!-- 侧边栏Category -->
|
|
122
|
+
<div v-if="isBlogHome"
|
|
123
|
+
class="flex w-full md:rounded-2xl px-5 py-5 flex-col justify-center gap-3 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md">
|
|
124
|
+
<div class="flex items-center justify-between text-xs uppercase tracking-wider opacity-70">
|
|
125
|
+
<span>{{ getLocalizedString('category', lang) || 'Category' }}</span>
|
|
126
|
+
<div class="flex items-center gap-2">
|
|
127
|
+
<a v-if="resolvedCategoryPageLink" :href="resolvedCategoryPageLink"
|
|
128
|
+
class="normal-case text-xs opacity-70 hover:opacity-100 transition">
|
|
129
|
+
{{ getLocalizedString('details', lang) || 'Details' }}
|
|
130
|
+
</a>
|
|
131
|
+
<span class="px-2 py-0.5 rounded-full border border-[var(--blog-border-c)]/70">
|
|
132
|
+
{{ getCategoryArray().length - 1 }}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="flex justify-start items-center flex-wrap gap-2 pt-1">
|
|
137
|
+
<div v-for="(cat, i) in getCategoryArray()" :key="cat[0]" class="cursor-pointer relative mt-1"
|
|
138
|
+
@click="activeCategory = cat[0]">
|
|
139
|
+
<div class="text-xs px-3 py-0.5 rounded-full border border-dashed transition hover:-translate-y-0.5" :class="{
|
|
140
|
+
'bg-[var(--blog-tag-bg-2)] text-[var(--blog-tag-text-2)] border-[var(--blog-tag-text-2)]': activeCategory === cat[0],
|
|
141
|
+
'bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]': activeCategory !== cat[0]
|
|
142
|
+
}">
|
|
143
|
+
{{ cat[0] == '' ? allText : cat[0] }}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<!-- 侧边栏Tag -->
|
|
150
|
+
<div v-if="isBlogHome"
|
|
151
|
+
class="flex w-full md:rounded-2xl px-5 py-5 flex-col justify-center gap-3 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md">
|
|
152
|
+
<!-- Tags -->
|
|
153
|
+
<div class="flex items-center justify-between text-xs uppercase tracking-wider opacity-70">
|
|
154
|
+
<span>{{ getLocalizedString('tags', lang) || 'Tags' }}</span>
|
|
155
|
+
<div class="flex items-center gap-2">
|
|
156
|
+
<a v-if="resolvedTagPageLink" :href="resolvedTagPageLink"
|
|
157
|
+
class="normal-case text-xs opacity-70 hover:opacity-100 transition">
|
|
158
|
+
{{ getLocalizedString('details', lang) || 'Details' }}
|
|
159
|
+
</a>
|
|
160
|
+
<span class="px-2 py-0.5 rounded-full border border-[var(--blog-border-c)]/70">
|
|
161
|
+
{{ getTagArray().length - 1 }}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<input
|
|
166
|
+
v-model="tagQuery"
|
|
167
|
+
type="text"
|
|
168
|
+
class="w-full h-8 border border-[var(--blog-border-c)]/70 rounded-md bg-[var(--vp-c-bg)]/60 px-2 text-xs outline-none focus:border-[var(--vp-c-brand-2)]"
|
|
169
|
+
:placeholder="(getLocalizedString('tags', lang) || 'Tags') + '...'"
|
|
170
|
+
@keydown.enter.prevent="addTagFromQuery"
|
|
171
|
+
/>
|
|
172
|
+
<div class="flex justify-start items-center flex-wrap gap-2 pt-1">
|
|
173
|
+
<div v-for="tag in selectedTags" :key="tag"
|
|
174
|
+
class="flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full border border-solid bg-[var(--blog-tag-bg-2)] text-[var(--blog-tag-text-2)] border-[var(--blog-tag-text-2)]">
|
|
175
|
+
<span>{{ tag }}</span>
|
|
176
|
+
<button type="button" class="ml-0.5 opacity-70 hover:opacity-100" @click.stop="removeTag(tag)">x</button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div v-if="displayedTags.length" class="flex justify-start items-center flex-wrap gap-2 pt-1">
|
|
180
|
+
<button v-for="(tag, i) in displayedTags" :key="tag[0]"
|
|
181
|
+
class="text-xs px-2.5 py-0.5 rounded-full border border-dashed transition hover:-translate-y-0.5 bg-[var(--blog-tag-bg-1)] text-[var(--blog-tag-text-1)] border-[var(--blog-tag-text-1)]"
|
|
182
|
+
@click="addTag(tag[0]); tagQuery = '';">
|
|
183
|
+
+ {{ tag[0] }}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<!-- 用户自定义组件 -->
|
|
188
|
+
<UpdateHeatmap
|
|
189
|
+
:archiveLink="resolvedArchivePageLink"
|
|
190
|
+
:archiveLabel="getLocalizedString('details', lang) || 'Details'"
|
|
191
|
+
:titleLabel="getLocalizedString('activity', lang) || 'Activity'"
|
|
192
|
+
/>
|
|
193
|
+
<WidgetCard />
|
|
194
|
+
</div>
|
|
195
|
+
<!-- 博客文章 -->
|
|
196
|
+
<div class="flex md:w-[77%] md:max-w-[77%] md:flex-none md:shrink-0 py-20 justify-center items-center gap-5 flex-col w-full px-3">
|
|
197
|
+
<slot :selectedTags="selectedTags" :activeCategory="activeCategory" />
|
|
198
|
+
</div>
|
|
199
|
+
<!-- 移动端个人信息显示 -->
|
|
200
|
+
<UserCard :isMobile="true" />
|
|
201
|
+
</div>
|
|
202
|
+
<Content v-if="showContent" />
|
|
203
|
+
</div>
|
|
204
|
+
</template>
|
|
205
|
+
|
|
206
|
+
<style>
|
|
207
|
+
.blog-home .VPContent {
|
|
208
|
+
padding-top: 0 !important;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.shadow-0 {
|
|
212
|
+
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
213
|
+
}
|
|
214
|
+
</style>
|
|
215
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="flex w-full md:rounded-2xl px-5 py-5 flex-col justify-center gap-3 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md">
|
|
4
|
+
<div class="flex items-center justify-between text-xs uppercase tracking-wider opacity-70">
|
|
5
|
+
<span class="text-[12px] opacity-90 normal-case tracking-normal">{{ titleLabel }}</span>
|
|
6
|
+
<a v-if="archiveLink" :href="archiveLink"
|
|
7
|
+
class="px-2.5 py-0.5 rounded-full border border-[var(--blog-border-c)]/70 bg-[var(--vp-c-bg)]/40 hover:bg-[var(--vp-c-bg)]/60 transition normal-case text-[11px]">
|
|
8
|
+
{{ archiveLabel }}
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="grid gap-2">
|
|
12
|
+
<div class="flex justify-center">
|
|
13
|
+
<div class="grid grid-flow-col grid-rows-7 auto-cols-[10px] gap-1">
|
|
14
|
+
<div
|
|
15
|
+
v-for="day in days"
|
|
16
|
+
:key="day.key"
|
|
17
|
+
class="w-[10px] h-[10px] rounded-[3px] transition"
|
|
18
|
+
:style="levelStyle(day.count, day.inRange)"
|
|
19
|
+
:title="`${day.key} · ${day.count}`"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="flex items-center justify-between text-[11px] opacity-70">
|
|
24
|
+
<span>{{ recentLabel }}</span>
|
|
25
|
+
<div class="flex items-center gap-1">
|
|
26
|
+
<span>Less</span>
|
|
27
|
+
<span class="w-[10px] h-[10px] rounded-[3px]" :style="legendStyle(0)"></span>
|
|
28
|
+
<span class="w-[10px] h-[10px] rounded-[3px]" :style="legendStyle(1)"></span>
|
|
29
|
+
<span class="w-[10px] h-[10px] rounded-[3px]" :style="legendStyle(2)"></span>
|
|
30
|
+
<span class="w-[10px] h-[10px] rounded-[3px]" :style="legendStyle(3)"></span>
|
|
31
|
+
<span class="w-[10px] h-[10px] rounded-[3px]" :style="legendStyle(4)"></span>
|
|
32
|
+
<span>More</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup>
|
|
40
|
+
import { computed } from 'vue';
|
|
41
|
+
import { useData } from 'vitepress';
|
|
42
|
+
import { parseDateValue } from '@velonor/engine';
|
|
43
|
+
import { data as posts } from '../posts.data.js';
|
|
44
|
+
|
|
45
|
+
const props = defineProps({
|
|
46
|
+
archiveLink: {
|
|
47
|
+
type: String,
|
|
48
|
+
default: '',
|
|
49
|
+
},
|
|
50
|
+
archiveLabel: {
|
|
51
|
+
type: String,
|
|
52
|
+
default: '',
|
|
53
|
+
},
|
|
54
|
+
titleLabel: {
|
|
55
|
+
type: String,
|
|
56
|
+
default: 'Activity',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { lang } = useData();
|
|
61
|
+
|
|
62
|
+
const today = new Date();
|
|
63
|
+
today.setHours(0, 0, 0, 0);
|
|
64
|
+
|
|
65
|
+
const endDate = new Date(today);
|
|
66
|
+
const startDate = new Date(today);
|
|
67
|
+
startDate.setDate(today.getDate() - 59);
|
|
68
|
+
|
|
69
|
+
const totalDays = 60;
|
|
70
|
+
|
|
71
|
+
const recentLabel = computed(() => {
|
|
72
|
+
const isZh = (lang.value || '').startsWith('zh');
|
|
73
|
+
return isZh ? `近 ${totalDays} 天` : `Last ${totalDays} days`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const formatKey = (date) => {
|
|
77
|
+
const year = date.getFullYear();
|
|
78
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
79
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
80
|
+
return `${year}-${month}-${day}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const counts = computed(() => {
|
|
84
|
+
const map = new Map();
|
|
85
|
+
posts.forEach((post) => {
|
|
86
|
+
const time = parseDateValue(post.frontmatter.date);
|
|
87
|
+
if (!time) return;
|
|
88
|
+
const date = new Date(time);
|
|
89
|
+
date.setHours(0, 0, 0, 0);
|
|
90
|
+
const key = formatKey(date);
|
|
91
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
92
|
+
});
|
|
93
|
+
return map;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const days = computed(() => {
|
|
97
|
+
const list = [];
|
|
98
|
+
for (let i = 0; i < totalDays; i += 1) {
|
|
99
|
+
const date = new Date(startDate);
|
|
100
|
+
date.setDate(startDate.getDate() + i);
|
|
101
|
+
const key = formatKey(date);
|
|
102
|
+
const count = counts.value.get(key) || 0;
|
|
103
|
+
const inRange = date >= startDate && date <= endDate;
|
|
104
|
+
list.push({ key, date, count, inRange });
|
|
105
|
+
}
|
|
106
|
+
return list;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const levelColor = (level) => {
|
|
110
|
+
const stops = [10, 28, 50, 75, 92];
|
|
111
|
+
const ratio = stops[Math.min(level, stops.length - 1)];
|
|
112
|
+
return `color-mix(in srgb, var(--vp-c-brand-1) ${ratio}%, transparent)`;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const levelStyle = (count, inRange) => {
|
|
116
|
+
if (!inRange) return { backgroundColor: levelColor(0) };
|
|
117
|
+
if (count <= 0) return { backgroundColor: levelColor(0) };
|
|
118
|
+
if (count === 1) return { backgroundColor: levelColor(1) };
|
|
119
|
+
if (count === 2) return { backgroundColor: levelColor(2) };
|
|
120
|
+
if (count === 3) return { backgroundColor: levelColor(3) };
|
|
121
|
+
return { backgroundColor: levelColor(4) };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const legendStyle = (level) => ({
|
|
125
|
+
backgroundColor: levelColor(level),
|
|
126
|
+
});
|
|
127
|
+
</script>
|
|
128
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- 电脑端 -->
|
|
3
|
+
<div class="hidden md:flex w-full md:rounded-2xl p-6 flex-col justify-center items-center gap-2 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md"
|
|
4
|
+
v-if="!props.isMobile && !userConfig?.hidden">
|
|
5
|
+
<!-- Avatar -->
|
|
6
|
+
<img :src="userConfig?.avatar" v-if="userConfig?.avatar" alt="avatar"
|
|
7
|
+
class="object-cover object-center w-24 h-24 rounded-full ring-2 ring-[var(--blog-border-c)]" />
|
|
8
|
+
<div class="mt-3" v-else></div>
|
|
9
|
+
<!-- Name -->
|
|
10
|
+
<div class="text-lg font-semibold text-center mt-2 tracking-tight">
|
|
11
|
+
{{ userConfig?.name }}
|
|
12
|
+
</div>
|
|
13
|
+
<!-- Description -->
|
|
14
|
+
<div class="text-center text-xs opacity-75 leading-relaxed">{{ userConfig?.describe }}</div>
|
|
15
|
+
<!-- Stats -->
|
|
16
|
+
<div class="flex justify-center items-center gap-12 w-full border-t-2 mt-3 border-[var(--blog-border-c)]/70">
|
|
17
|
+
<div class="flex flex-col justify-center items-center gap-1">
|
|
18
|
+
<div class="text-[10px] uppercase tracking-wider opacity-70">{{ postsText }}</div>
|
|
19
|
+
<div class="text-xl font-semibold">{{ posts.length }}</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="flex flex-col justify-center items-center gap-1">
|
|
22
|
+
<div class="text-[10px] uppercase tracking-wider opacity-70">{{ tagsText }}</div>
|
|
23
|
+
<div class="text-xl font-semibold">{{ uniqueTagCount }}</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<!-- 移动端个人信息显示 -->
|
|
28
|
+
<div class="flex md:hidden justify-center items-center w-full mt-8 flex-col gap-3" v-else-if="!userConfig?.hidden">
|
|
29
|
+
<img :src="userConfig?.avatar" v-if="userConfig?.avatar" alt="avatar"
|
|
30
|
+
class="object-cover object-center w-32 rounded-full" />
|
|
31
|
+
<!-- 昵称 -->
|
|
32
|
+
<div class="text-2xl font-bold text-center">{{ userConfig?.name }}</div>
|
|
33
|
+
<!-- 签名 -->
|
|
34
|
+
<div class="text-center text-sm">{{ userConfig?.describe }}</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
|
|
40
|
+
// TODO 待优化
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
import { data as posts } from '../posts.data.js';
|
|
43
|
+
|
|
44
|
+
import { useData } from 'vitepress';
|
|
45
|
+
import { computed } from 'vue';
|
|
46
|
+
import { getLocalizedString } from '../utils/constants';
|
|
47
|
+
import { useTags } from '../composables/useTags';
|
|
48
|
+
|
|
49
|
+
const { theme, lang } = useData<Open17Config>();
|
|
50
|
+
|
|
51
|
+
const userConfig = theme.value.blog ? theme.value.blog.user : null;
|
|
52
|
+
|
|
53
|
+
const { uniqueTagCount } = useTags();
|
|
54
|
+
|
|
55
|
+
const postsText = computed(() => getLocalizedString('posts', lang.value));
|
|
56
|
+
const tagsText = computed(() => getLocalizedString('tags', lang.value));
|
|
57
|
+
|
|
58
|
+
const props = defineProps<{
|
|
59
|
+
isMobile: Boolean
|
|
60
|
+
}>();
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-for="widget in widgets"
|
|
3
|
+
class="flex w-full min-w-0 md:rounded-2xl flex-col py-5 justify-center gap-4 dark:shadow-none shadow-lg border-2 border-[var(--blog-border-c)] bg-[var(--vp-c-blog-bg)]/95 backdrop-blur-md overflow-hidden">
|
|
4
|
+
<div class="flex w-full justify-between items-center px-5">
|
|
5
|
+
<div class="text-base font-bold tracking-tight" v-if="widget.name">
|
|
6
|
+
{{ widget.name }}
|
|
7
|
+
</div>
|
|
8
|
+
<a v-if="widget.link" :href="widget.link" class="opacity-70 hover:opacity-100 transition">
|
|
9
|
+
<IconLink />
|
|
10
|
+
</a>
|
|
11
|
+
</div>
|
|
12
|
+
<div v-html="widget.html"
|
|
13
|
+
class="w-full min-w-0 max-w-full relative px-5 pb-2 text-sm leading-relaxed overflow-hidden break-words"></div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
<script lang="ts" setup>
|
|
19
|
+
import IconLink from './icon/IconLink.vue';
|
|
20
|
+
import { useData } from 'vitepress';
|
|
21
|
+
const { theme, frontmatter } = useData<Open17Config>();
|
|
22
|
+
const widgets = [
|
|
23
|
+
...(theme.value.blog?.widgets || []),
|
|
24
|
+
...(frontmatter.value.widgets || []),
|
|
25
|
+
];
|
|
26
|
+
</script>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<svg
|
|
3
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
4
|
+
fill="none"
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
stroke-width="1.5"
|
|
7
|
+
stroke="currentColor"
|
|
8
|
+
class="w-5 h-5"
|
|
9
|
+
>
|
|
10
|
+
<path
|
|
11
|
+
stroke-linecap="round"
|
|
12
|
+
stroke-linejoin="round"
|
|
13
|
+
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
|
14
|
+
/>
|
|
15
|
+
</svg>
|
|
16
|
+
</template>
|