clarity-theme 0.1.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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/README.zh-CN.md +297 -0
  4. package/app/app.config.ts +110 -0
  5. package/app/app.vue +5 -0
  6. package/app/assets/css/_variable.scss +8 -0
  7. package/app/assets/css/animation.scss +50 -0
  8. package/app/assets/css/article.scss +152 -0
  9. package/app/assets/css/color.scss +58 -0
  10. package/app/assets/css/font.scss +35 -0
  11. package/app/assets/css/main.scss +230 -0
  12. package/app/assets/css/reusable.scss +186 -0
  13. package/app/assets/icons/solar-rewind-back-bold-duotone.svg +1 -0
  14. package/app/assets/icons/solar-rewind-forward-bold-duotone.svg +1 -0
  15. package/app/components/blog/BlogAside.vue +63 -0
  16. package/app/components/blog/BlogFooter.vue +61 -0
  17. package/app/components/blog/BlogHeader.global.vue +146 -0
  18. package/app/components/blog/BlogPanel.vue +79 -0
  19. package/app/components/blog/BlogSidebar.vue +160 -0
  20. package/app/components/blog/BlogWidget.vue +134 -0
  21. package/app/components/blog/Mask.vue +42 -0
  22. package/app/components/blog/SkipToContent.vue +22 -0
  23. package/app/components/blog/ThemeToggle.vue +50 -0
  24. package/app/components/content/Alert.vue +96 -0
  25. package/app/components/content/Badge.vue +90 -0
  26. package/app/components/content/Blur.vue +22 -0
  27. package/app/components/content/CardList.vue +32 -0
  28. package/app/components/content/Chat.vue +79 -0
  29. package/app/components/content/Copy.vue +134 -0
  30. package/app/components/content/EmojiClock.vue +53 -0
  31. package/app/components/content/FeedCard.vue +212 -0
  32. package/app/components/content/FeedGroup.vue +130 -0
  33. package/app/components/content/Folding.vue +69 -0
  34. package/app/components/content/Key.vue +164 -0
  35. package/app/components/content/LinkBanner.vue +59 -0
  36. package/app/components/content/LinkCard.vue +74 -0
  37. package/app/components/content/MdTitle.vue +26 -0
  38. package/app/components/content/Mermaid.vue +133 -0
  39. package/app/components/content/MusicScore.vue +178 -0
  40. package/app/components/content/Pic.vue +55 -0
  41. package/app/components/content/Poetry.vue +41 -0
  42. package/app/components/content/ProseA.vue +40 -0
  43. package/app/components/content/ProseCode.vue +95 -0
  44. package/app/components/content/ProsePre.vue +291 -0
  45. package/app/components/content/ProseTable.vue +70 -0
  46. package/app/components/content/Quote.vue +51 -0
  47. package/app/components/content/Tab.vue +100 -0
  48. package/app/components/content/Timeline.vue +73 -0
  49. package/app/components/content/Tip.vue +52 -0
  50. package/app/components/content/VideoEmbed.vue +127 -0
  51. package/app/components/partial/Button.vue +98 -0
  52. package/app/components/partial/DlGroup.vue +69 -0
  53. package/app/components/partial/Dropdown.vue +54 -0
  54. package/app/components/partial/Error.vue +70 -0
  55. package/app/components/partial/Expand.vue +51 -0
  56. package/app/components/partial/IconNavList.vue +42 -0
  57. package/app/components/partial/Pagination.vue +113 -0
  58. package/app/components/partial/RadioGroup.vue +77 -0
  59. package/app/components/partial/Secret.vue +23 -0
  60. package/app/components/partial/Slider.vue +96 -0
  61. package/app/components/partial/Toggle.vue +70 -0
  62. package/app/components/popover/Lightbox.vue +77 -0
  63. package/app/components/popover/Search.vue +214 -0
  64. package/app/components/popover/SearchItem.vue +64 -0
  65. package/app/components/post/Archive.vue +104 -0
  66. package/app/components/post/Article.vue +138 -0
  67. package/app/components/post/Comment.vue +275 -0
  68. package/app/components/post/Excerpt.vue +66 -0
  69. package/app/components/post/OrderToggle.vue +101 -0
  70. package/app/components/post/PostFooter.vue +80 -0
  71. package/app/components/post/PostHeader.vue +147 -0
  72. package/app/components/post/PostSurround.vue +111 -0
  73. package/app/components/post/Slide.vue +215 -0
  74. package/app/components/util/Date.vue +62 -0
  75. package/app/components/util/HydrateSafe.vue +10 -0
  76. package/app/components/util/Img.vue +38 -0
  77. package/app/components/util/Link.vue +11 -0
  78. package/app/components/widget/BlogLog.vue +27 -0
  79. package/app/components/widget/BlogStats.vue +39 -0
  80. package/app/components/widget/BlogTech.vue +65 -0
  81. package/app/components/widget/Empty.vue +3 -0
  82. package/app/components/widget/Toc.vue +129 -0
  83. package/app/composables/useArticle.ts +118 -0
  84. package/app/composables/useAvoid.ts +87 -0
  85. package/app/composables/useCopy.ts +21 -0
  86. package/app/composables/usePagination.ts +70 -0
  87. package/app/composables/useShiki.ts +129 -0
  88. package/app/composables/useToc.ts +57 -0
  89. package/app/composables/useWidgets.ts +56 -0
  90. package/app/error.vue +34 -0
  91. package/app/layouts/default.vue +66 -0
  92. package/app/pages/[...slug].vue +59 -0
  93. package/app/pages/archive.vue +207 -0
  94. package/app/pages/index.vue +83 -0
  95. package/app/pages/link.vue +65 -0
  96. package/app/pages/preview.vue +59 -0
  97. package/app/plugins/easter-egg.ts +23 -0
  98. package/app/plugins/init.ts +6 -0
  99. package/app/plugins/tippy.ts +22 -0
  100. package/app/shiki.config.ts +8 -0
  101. package/app/stores/layout.ts +34 -0
  102. package/app/stores/search.ts +37 -0
  103. package/app/types/article.ts +16 -0
  104. package/app/types/feed.ts +1 -0
  105. package/app/types/index.ts +14 -0
  106. package/app/types/modal.ts +16 -0
  107. package/app/types/nav.ts +11 -0
  108. package/app/utils/anim.ts +37 -0
  109. package/app/utils/img.ts +83 -0
  110. package/config/app.ts +88 -0
  111. package/config/content.d.mts +1 -0
  112. package/config/content.mjs +69 -0
  113. package/config/content.ts +91 -0
  114. package/config/define.mjs +21 -0
  115. package/config/define.ts +23 -0
  116. package/config/feed.ts +42 -0
  117. package/config/feeds.empty.ts +4 -0
  118. package/config/index.d.mts +1 -0
  119. package/config/index.mjs +6 -0
  120. package/config/index.ts +6 -0
  121. package/config/public.ts +71 -0
  122. package/config/schema.d.mts +1 -0
  123. package/config/schema.mjs +103 -0
  124. package/config/schema.ts +129 -0
  125. package/config/server.ts +35 -0
  126. package/img/index.d.mts +1 -0
  127. package/img/index.mjs +78 -0
  128. package/img/index.ts +8 -0
  129. package/modules/clarity-config/anti-mirror-client.ts +11 -0
  130. package/modules/clarity-config/index.ts +324 -0
  131. package/nuxt.config.ts +225 -0
  132. package/package.json +148 -0
  133. package/public/assets/atom.css +274 -0
  134. package/public/assets/atom.xsl +110 -0
  135. package/public/fonts/AlimamaFangYuanTi.woff2 +0 -0
  136. package/remark-plugins/rehype-meta-slots.d.mts +1 -0
  137. package/remark-plugins/rehype-meta-slots.mjs +49 -0
  138. package/remark-plugins/rehype-meta-slots.ts +58 -0
  139. package/remark-plugins/remark-code-component.d.mts +1 -0
  140. package/remark-plugins/remark-code-component.mjs +25 -0
  141. package/remark-plugins/remark-code-component.ts +41 -0
  142. package/server/api/stats.get.ts +109 -0
  143. package/server/routes/atom.xml.get.ts +101 -0
  144. package/server/routes/subscriptions.opml.get.ts +70 -0
  145. package/server/utils/clarity.ts +11 -0
  146. package/shared/types/content.ts +30 -0
  147. package/shared/utils/clarity.ts +34 -0
  148. package/shared/utils/icon.ts +218 -0
  149. package/shared/utils/link.ts +52 -0
  150. package/shared/utils/str.ts +88 -0
  151. package/shared/utils/time.ts +116 -0
@@ -0,0 +1,56 @@
1
+ import { pascalCase } from 'es-toolkit/string'
2
+ import {
3
+ ContentRenderer,
4
+ LazyBlogWidget,
5
+ LazyWidgetBlogLog,
6
+ LazyWidgetBlogStats,
7
+ LazyWidgetBlogTech,
8
+ LazyWidgetEmpty,
9
+ LazyWidgetToc,
10
+ } from '#components'
11
+
12
+ // @keep-sorted
13
+ const rawWidgets = {
14
+ LazyWidgetBlogLog,
15
+ LazyWidgetBlogStats,
16
+ LazyWidgetBlogTech,
17
+ LazyWidgetEmpty,
18
+ LazyWidgetToc,
19
+ }
20
+
21
+ type RawWidgetName = keyof typeof rawWidgets
22
+
23
+ /** 若首字母大写还需移除`-`前缀 */
24
+ type KebabCase<S extends string> = S extends `${infer First}${infer Rest}`
25
+ ? `${First extends Capitalize<First> ? '-' : ''}${Lowercase<First>}${KebabCase<Rest>}`
26
+ : ''
27
+
28
+ type RemovePrefix<S extends string, Prefix extends string> = S extends `${Prefix}${infer Rest}` ? Rest : S
29
+
30
+ export type WidgetName = RemovePrefix<KebabCase<RawWidgetName>, '-lazy-widget-'> | `meta-aside-${string}`
31
+
32
+ export default function useWidgets(widgetList: MaybeRefOrGetter<WidgetName[]>) {
33
+ const { metaSlots } = useArticle()
34
+
35
+ function renderMetaSlots(widgetName: WidgetName) {
36
+ const slotsTree = metaSlots.value?.[widgetName.slice('meta-'.length)]
37
+ return h(
38
+ LazyBlogWidget,
39
+ { card: !slotsTree, ...slotsTree?.props },
40
+ () => slotsTree
41
+ ? h(ContentRenderer, { value: slotsTree })
42
+ : `${widgetName} 不存在`,
43
+ )
44
+ }
45
+
46
+ const widgets = computed(() => toValue(widgetList).map(widgetName => ({
47
+ name: widgetName,
48
+ comp: widgetName.startsWith('meta-aside-')
49
+ ? renderMetaSlots(widgetName)
50
+ : rawWidgets[`LazyWidget${pascalCase(widgetName)}` as RawWidgetName],
51
+ })))
52
+
53
+ return {
54
+ widgets,
55
+ }
56
+ }
package/app/error.vue ADDED
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import type { NuxtError } from '#app'
3
+
4
+ defineProps<{
5
+ error: NuxtError & { url?: string }
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <NuxtLayout>
11
+ <template #aside>
12
+ <WidgetBlogLog />
13
+ </template>
14
+
15
+ <div class="app-error">
16
+ <ZError
17
+ :code="error.stack"
18
+ :message="error.url"
19
+ :title="`[${error.status}] ${error.message}`"
20
+ >
21
+ <template #operation>
22
+ <ZButton text="返回主页" @click="clearError({ redirect: '/' })" />
23
+ <ZButton text="尝试忽略" @click="clearError()" />
24
+ </template>
25
+ </ZError>
26
+ </div>
27
+ </NuxtLayout>
28
+ </template>
29
+
30
+ <style lang="scss" scoped>
31
+ .app-error {
32
+ margin: 1rem;
33
+ }
34
+ </style>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ const { slots } = inject<any>(Symbol.for('dxup:layout-slots')) || {}
3
+ </script>
4
+
5
+ <template>
6
+ <NuxtLoadingIndicator />
7
+ <NuxtRouteAnnouncer :style="{ position: 'absolute' }" />
8
+ <BlogSkipToContent />
9
+ <BlogSidebar />
10
+ <div id="content">
11
+ <main id="main-content">
12
+ <slot />
13
+ <BlogFooter />
14
+ </main>
15
+ <BlogAside>
16
+ <slot name="aside" />
17
+ </BlogAside>
18
+ </div>
19
+ <BlogPanel :has-aside="!!slots?.aside" />
20
+ <BikariyaModals />
21
+ </template>
22
+
23
+ <!-- eslint-disable-next-line vue/enforce-style-attribute -->
24
+ <style lang="scss">
25
+ #blog-root {
26
+ display: flex;
27
+ justify-content: center;
28
+ gap: 1rem;
29
+ min-width: 0;
30
+ }
31
+
32
+ #blog-sidebar, #blog-aside {
33
+ flex: 0 0 280px; // 防止搜索框 grow
34
+ position: sticky;
35
+ top: 0;
36
+ height: 100vh;
37
+ height: 100dvh;
38
+ min-width: 0; // 防止搜索框撑开页面
39
+ scrollbar-width: thin;
40
+
41
+ @media (max-width: $breakpoint-widescreen) {
42
+ flex-shrink: 0.2;
43
+ }
44
+ }
45
+
46
+ #content {
47
+ display: flex;
48
+ gap: 1rem;
49
+
50
+ // 若设置的是 max-width,则内部 main 宽度为 fit-content,可能无法撑满
51
+ // 此时即使设置 flex-grow,也会影响 #sidebar 无法正确 shrink
52
+ width: $breakpoint-widescreen;
53
+ min-width: 0; // 解决父级 flexbox 设置 justify-content: center 时溢出左侧消失的问题
54
+
55
+ // 此处不建议给内容设置 padding
56
+ > #main-content {
57
+ flex-grow: 1; // 使较小宽度的内容占满
58
+
59
+ // overflow: hidden; // 会使一部分元素吸顶失效
60
+
61
+ // 使内容正确计算宽度而不横向溢出
62
+ // 也可设置 width: 0 或者 contain: inline-size(兼容性不佳)
63
+ min-width: 0;
64
+ }
65
+ }
66
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+
4
+ const { data: post } = await useAsyncData(
5
+ `content:${route.path}`,
6
+ () => queryCollection('content').path(route.path).first(),
7
+ )
8
+
9
+ const excerpt = computed(() => post.value?.description || '')
10
+ const asideWidgetNames = computed<WidgetName[]>(() => {
11
+ if (!post.value)
12
+ return ['blog-log']
13
+ return (post.value.meta?.aside as WidgetName[] | undefined) ?? ['toc']
14
+ })
15
+ const { widgets } = useWidgets(asideWidgetNames)
16
+
17
+ if (post.value) {
18
+ useSeoMeta({
19
+ title: post.value.title,
20
+ ogType: 'article',
21
+ ogImage: post.value.image,
22
+ description: post.value.description,
23
+ })
24
+ }
25
+ else {
26
+ const event = useRequestEvent()
27
+ event && setResponseStatus(event, 404)
28
+ route.meta.title = '404'
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <template #aside>
34
+ <!-- 更换页面时相同 key 的组件不会更新 -->
35
+ <component :is="widget.comp" v-for="widget in widgets" :key="widget.name" />
36
+ </template>
37
+
38
+ <template v-if="post">
39
+ <PostHeader v-bind="post" />
40
+ <PostExcerpt v-if="excerpt" :excerpt />
41
+ <!-- 使用 float-in 动画会导致搜索跳转不准确 -->
42
+ <ContentRenderer
43
+ class="article"
44
+ :class="getPostTypeClassName(post?.type, { prefix: 'md' })"
45
+ :value="post"
46
+ tag="article"
47
+ />
48
+
49
+ <PostFooter v-bind="post" />
50
+ <PostSurround />
51
+ <PostComment />
52
+ </template>
53
+
54
+ <ZError
55
+ v-else
56
+ icon="line-md:document-delete-twotone"
57
+ title="内容为空或页面不存在"
58
+ />
59
+ </template>
@@ -0,0 +1,207 @@
1
+ <script setup lang="ts">
2
+ import type { ArticleProps } from '../types/article'
3
+ import { groupBy } from 'es-toolkit/array'
4
+ import { sumBy } from 'es-toolkit/math'
5
+ import { mapValues } from 'es-toolkit/object'
6
+
7
+ const clarity = useClarityConfig()
8
+ useSeoMeta({
9
+ title: '归档',
10
+ description: `${clarity.site.title}的所有文章归档。`,
11
+ })
12
+ const birthYear = computed(() => clarity.component.stats.birthYear)
13
+ const showTuning = ref(false)
14
+ const spacing = ref(0)
15
+ const column = ref(1)
16
+
17
+ const tuningRef = useTemplateRef('tuning-panel')
18
+ useAvoidTarget(tuningRef, showTuning)
19
+
20
+ const { data: listRaw } = await useAsyncData('posts:index', () => getArticleIndexOptions(), { default: () => [] })
21
+ const { listSorted, isAscending, sortOrder } = useArticleSort(listRaw)
22
+ const { category, categories, listCategorized } = useCategory(listSorted)
23
+
24
+ const listGrouped = computed(() => {
25
+ const groupList = Object.entries(groupBy(listCategorized.value, getArticleYear))
26
+ return isAscending.value ? groupList : groupList.reverse()
27
+ })
28
+
29
+ // 不能使用 /api/stats,因为可能切换分组方式
30
+ const yearlyWordCount = computed(() =>
31
+ mapValues(Object.fromEntries(listGrouped.value), (articles) => {
32
+ const total = sumBy(articles, a => a.readingTime?.words ?? 0)
33
+ return formatNumber(total)
34
+ }),
35
+ )
36
+
37
+ function getArticleYear(article: ArticleProps) {
38
+ try {
39
+ return toZonedTemporal((article as any)[sortOrder.value] as string, clarity.site.timezone).year.toString()
40
+ }
41
+ catch {
42
+ return ''
43
+ }
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <template #aside>
49
+ <WidgetBlogStats />
50
+ <WidgetBlogLog />
51
+ </template>
52
+
53
+ <div class="archive proper-height">
54
+ <PostOrderToggle
55
+ v-model:is-ascending="isAscending"
56
+ v-model:sort-order="sortOrder"
57
+ v-model:category="category"
58
+ :categories
59
+ >
60
+ <ZSecret>
61
+ <ZToggle
62
+ v-model="showTuning"
63
+ label="密度调节"
64
+ />
65
+ </ZSecret>
66
+ </PostOrderToggle>
67
+
68
+ <section
69
+ v-for="[year, yearGroup] in listGrouped"
70
+ :key="year"
71
+ class="archive-group"
72
+ :class="{ 'hide-info': column > 1 }"
73
+ :style="{
74
+ '--archive-item-gap': `${spacing}em`,
75
+ '--archive-item-column': column,
76
+ }"
77
+ >
78
+ <div class="archive-title">
79
+ <h2 class="archive-year">
80
+ {{ year }}
81
+ </h2>
82
+
83
+ <div v-if="birthYear" class="archive-age">
84
+ <span>{{ Number(year) - birthYear }}</span>
85
+ <span class="age-label">岁</span>
86
+ </div>
87
+
88
+ <div class="archive-info">
89
+ <span>{{ yearlyWordCount[year] }}字</span>
90
+ <span>{{ yearGroup?.length }}篇</span>
91
+ </div>
92
+ </div>
93
+
94
+ <TransitionGroup tag="menu" class="archive-list" name="float-in">
95
+ <PostArchive
96
+ v-for="article, index in yearGroup"
97
+ :key="article.path"
98
+ v-bind="article"
99
+ :to="article.path"
100
+ :show-category="column < 3"
101
+ :use-updated="sortOrder === 'updated'"
102
+ :style="getFixedDelay(index * 0.03)"
103
+ />
104
+ </TransitionGroup>
105
+ </section>
106
+
107
+ <div v-if="showTuning" ref="tuning-panel" class="archive-tuning card">
108
+ <ZSlider
109
+ v-model="spacing"
110
+ label="间距"
111
+ :spring-min="-0.4"
112
+ :spring-max="0.1"
113
+ :list="['-0.3', '0']"
114
+ min="-1"
115
+ max=".2"
116
+ step=".1"
117
+ />
118
+
119
+ <ZSlider
120
+ v-model="column"
121
+ label="列数"
122
+ min="1"
123
+ max="8"
124
+ />
125
+ </div>
126
+ </div>
127
+ </template>
128
+
129
+ <style lang="scss" scoped>
130
+ .archive {
131
+ padding: 1rem; // 防止内部 outline 被 mask
132
+ mask-image: linear-gradient(#FFF 50%, #FFF7);
133
+ }
134
+
135
+ .archive-group {
136
+ margin: 1rem 0 3rem;
137
+
138
+ > .archive-list {
139
+ display: grid;
140
+ grid-template-columns: repeat(var(--archive-item-column), 1fr);
141
+ column-gap: calc((5 - var(--archive-item-column)) * 0.2em);
142
+ }
143
+
144
+ &.hide-info :deep(.dim-hover) {
145
+ display: none;
146
+ }
147
+ }
148
+
149
+ .archive-tuning {
150
+ position: sticky;
151
+ bottom: min(2em, 5%);
152
+
153
+ > .z-slider {
154
+ margin: 0.5em 0.8em;
155
+ }
156
+ }
157
+
158
+ .archive-title {
159
+ display: flex;
160
+ justify-content: space-between;
161
+ gap: 1em;
162
+ position: sticky;
163
+ opacity: 0.5;
164
+ top: 0;
165
+ font-size: min(1.5em, 5vw);
166
+ color: transparent;
167
+ transition: color 0.2s;
168
+
169
+ &::selection, :hover > & {
170
+ color: var(--c-text-3);
171
+ }
172
+
173
+ :hover > & .archive-age {
174
+ opacity: 0;
175
+ }
176
+
177
+ > .archive-year, .archive-age {
178
+ margin-bottom: -0.3em;
179
+ mask-image: linear-gradient(#FFF 50%, transparent);
180
+ font-family: var(--font-stroke-free);
181
+ font-size: 3em;
182
+ font-variant-numeric: tabular-nums;
183
+ font-weight: 800;
184
+ line-height: 1;
185
+ z-index: -1;
186
+ -webkit-text-stroke: 1px var(--c-text-3);
187
+ }
188
+
189
+ > .archive-age {
190
+ position: absolute;
191
+ inset-inline-end: 0;
192
+ transition: opacity 0.2s;
193
+
194
+ > .age-label {
195
+ font-size: 0.5em;
196
+ vertical-align: super;
197
+ }
198
+ }
199
+
200
+ > .archive-info {
201
+ display: flex;
202
+ flex-wrap: wrap;
203
+ justify-content: flex-end;
204
+ column-gap: 0.5em;
205
+ }
206
+ }
207
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { orderBy } from 'es-toolkit/array'
3
+
4
+ const clarity = useClarityConfig()
5
+ useSeoMeta({
6
+ description: clarity.site.description,
7
+ ogImage: clarity.site.author.avatar,
8
+ })
9
+
10
+ const { data: listRaw } = await useAsyncData('posts:index', () => getArticleIndexOptions(), { default: () => [] })
11
+ const { listSorted, isAscending, sortOrder } = useArticleSort(listRaw, { bindDirectionQuery: 'asc', bindOrderQuery: 'sort' })
12
+ const { category, categories, listCategorized } = useCategory(listSorted, { bindQuery: 'category' })
13
+ const { page, totalPages, listPaged } = usePagination(listCategorized, { bindQuery: 'page' })
14
+
15
+ watch(category, () => {
16
+ page.value = 1
17
+ })
18
+
19
+ useSeoMeta({ title: () => (page.value > 1 ? `第${page.value}页` : '') })
20
+
21
+ const listRecommended = computed(() => orderBy(
22
+ listRaw.value.filter(item => item.recommend !== null),
23
+ ['recommend', 'date'],
24
+ ['desc'],
25
+ ))
26
+
27
+ const { data: previewCount } = useAsyncData(
28
+ 'previews:count',
29
+ () => queryCollection('content').where('stem', 'LIKE', 'previews/%').count(),
30
+ )
31
+ </script>
32
+
33
+ <template>
34
+ <template #aside>
35
+ <WidgetBlogStats />
36
+ <WidgetBlogTech />
37
+ </template>
38
+
39
+ <BlogHeader class="mobile-only" to="/" tag="h1" />
40
+
41
+ <UtilHydrateSafe>
42
+ <PostSlide v-if="listRecommended.length && page === 1 && !category" :list="listRecommended" />
43
+
44
+ <div class="post-list">
45
+ <PostOrderToggle
46
+ v-model:is-ascending="isAscending"
47
+ v-model:sort-order="sortOrder"
48
+ v-model:category="category"
49
+ :categories
50
+ >
51
+ <ZSecret>
52
+ <UtilLink v-if="previewCount" to="/preview" class="preview-entrance">
53
+ <Icon name="tabler:shield-lock" />
54
+ 查看预览文章
55
+ </UtilLink>
56
+ </ZSecret>
57
+ </PostOrderToggle>
58
+
59
+ <TransitionGroup tag="menu" class="proper-height" name="float-in">
60
+ <PostArticle
61
+ v-for="article, index in listPaged"
62
+ :key="article.path"
63
+ v-bind="article"
64
+ :to="article.path"
65
+ :use-updated="sortOrder === 'updated'"
66
+ :style="getFixedDelay(index * 0.05)"
67
+ />
68
+ </TransitionGroup>
69
+
70
+ <ZPagination v-model="page" sticky avoid :total-pages="totalPages" />
71
+ </div>
72
+ </UtilHydrateSafe>
73
+ </template>
74
+
75
+ <style lang="scss" scoped>
76
+ .post-list {
77
+ margin: 1rem;
78
+ }
79
+
80
+ .float-in-leave-to {
81
+ position: absolute;
82
+ }
83
+ </style>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import feeds from '#clarity/feeds'
3
+
4
+ const clarity = useClarityConfig()
5
+ const myFeed = useClaritySiteFeedEntry()
6
+
7
+ const { data: postLink } = await useAsyncData(
8
+ 'content:/link',
9
+ () => queryCollection('content').path('/link').first(),
10
+ )
11
+
12
+ useSeoMeta({
13
+ title: '友链',
14
+ ogType: 'profile',
15
+ description: `${clarity.site.title}的友链页面,收集了添加他为友链的网站和他订阅的网站列表。`,
16
+ })
17
+
18
+ const copyFields = {
19
+ 博主: myFeed.author,
20
+ 标题: myFeed.title,
21
+ 介绍: myFeed.desc,
22
+ 网址: myFeed.link,
23
+ 头像: myFeed.avatar,
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div class="mobile-only">
29
+ <BlogHeader to="/" suffix="友链" tag="h1" />
30
+ </div>
31
+
32
+ <FeedGroup
33
+ v-for="group in feeds"
34
+ :key="group.name"
35
+ v-bind="group"
36
+ :shuffle="clarity.link.randomInGroup"
37
+ />
38
+
39
+ <Tab :tabs="['我的博客信息', '申请友链']" center>
40
+ <template #tab1>
41
+ <div class="link-tab">
42
+ <FeedCard v-bind="myFeed" />
43
+ <Copy v-for="(code, prompt) in copyFields" :key="prompt" :prompt :code />
44
+ </div>
45
+ </template>
46
+ <template #tab2>
47
+ <ContentRenderer
48
+ v-if="postLink"
49
+ :value="postLink"
50
+ class="article"
51
+ />
52
+ <p v-else class="text-center">
53
+ 可于 link.md 配置友链补充说明。
54
+ </p>
55
+ </template>
56
+ </Tab>
57
+
58
+ <PostComment />
59
+ </template>
60
+
61
+ <style lang="scss" scoped>
62
+ .link-tab {
63
+ margin: 1rem;
64
+ }
65
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ const clarity = useClarityConfig()
3
+ useSeoMeta({
4
+ title: '预览',
5
+ description: `${clarity.site.title}的文章预览。`,
6
+ })
7
+ const { data: listRaw } = await useAsyncData('previews:index', () => getArticleIndexOptions('previews/%'), { default: () => [] })
8
+ const { listSorted, isAscending, sortOrder } = useArticleSort(listRaw)
9
+ const { category, categories, listCategorized } = useCategory(listSorted)
10
+ </script>
11
+
12
+ <template>
13
+ <template #aside>
14
+ <WidgetBlogLog />
15
+ </template>
16
+
17
+ <div class="preview">
18
+ <div class="preview-header">
19
+ <h1>
20
+ <UtilLink class="mobile-only" to="/" title="返回首页">
21
+ <Icon name="tabler:chevron-left" />
22
+ </UtilLink>预览
23
+ </h1>
24
+ <PostOrderToggle
25
+ v-model:is-ascending="isAscending"
26
+ v-model:sort-order="sortOrder"
27
+ v-model:category="category"
28
+ :categories
29
+ />
30
+ </div>
31
+ <p>勇敢的人探索世界。这里是一些还未发布的文章。</p>
32
+
33
+ <menu class="proper-height">
34
+ <PostArticle
35
+ v-for="article in listCategorized"
36
+ :key="article.path"
37
+ v-bind="article"
38
+ :to="article.path"
39
+ :use-updated="sortOrder === 'updated'"
40
+ />
41
+ </menu>
42
+ </div>
43
+ </template>
44
+
45
+ <style lang="scss" scoped>
46
+ .preview {
47
+ margin: 1rem;
48
+ }
49
+
50
+ .preview-header {
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: space-between;
54
+
55
+ h1 {
56
+ mask-image: linear-gradient(#FFF, transparent);
57
+ }
58
+ }
59
+ </style>
@@ -0,0 +1,23 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const dir = useTextDirection()
3
+ const konamiCode = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'KeyB', 'KeyA']
4
+ let currentStep = 0
5
+
6
+ function step(step: boolean, resetForward: boolean) {
7
+ if (step === false) {
8
+ currentStep = resetForward ? 1 : 0
9
+ return
10
+ }
11
+
12
+ currentStep++
13
+
14
+ if (currentStep === konamiCode.length) {
15
+ dir.value = dir.value === 'ltr' ? 'rtl' : 'ltr'
16
+ currentStep = 0
17
+ }
18
+ }
19
+
20
+ useEventListener('keydown', ({ code }) => {
21
+ step(code === konamiCode[currentStep], code === konamiCode[0])
22
+ })
23
+ })
@@ -0,0 +1,6 @@
1
+ export default defineNuxtPlugin(() => {
2
+ // 开发环境禁用 Umami 统计
3
+ if (import.meta.client && import.meta.dev) {
4
+ localStorage.setItem('umami.disabled', 'true')
5
+ }
6
+ })
@@ -0,0 +1,22 @@
1
+ import type { directive, Tippy } from 'vue-tippy'
2
+ import VueTippy, { roundArrow } from 'vue-tippy'
3
+ import 'tippy.js/dist/svg-arrow.css'
4
+
5
+ declare module 'vue' {
6
+ interface GlobalComponents {
7
+ Tooltip: typeof Tippy
8
+ }
9
+ interface GlobalDirectives {
10
+ vTip: typeof directive
11
+ }
12
+ }
13
+
14
+ export default defineNuxtPlugin((nuxtApp) => {
15
+ nuxtApp.vueApp.use(VueTippy, {
16
+ component: 'Tooltip',
17
+ directive: 'tip',
18
+ defaultProps: {
19
+ arrow: roundArrow,
20
+ },
21
+ })
22
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from '#shiki/config'
2
+
3
+ export default defineConfig({
4
+ themes: {
5
+ light: () => import('shiki/themes/catppuccin-latte.mjs'),
6
+ dark: () => import('shiki/themes/one-dark-pro.mjs'),
7
+ },
8
+ })