@sugarat/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.
@@ -0,0 +1,218 @@
1
+ <template>
2
+ <div class="theme-blog-popover" v-show="show">
3
+ <div class="header">
4
+ <div class="title-wrapper">
5
+ <el-icon size="20px"><Flag /></el-icon>
6
+ <span class="title">{{ popoverProps?.title }}</span>
7
+ </div>
8
+ <el-icon @click="show = false" class="close-icon" size="20px"
9
+ ><CircleCloseFilled
10
+ /></el-icon>
11
+ </div>
12
+ <div class="body content" v-if="bodyContent.length">
13
+ <PopoverValue v-for="(v, idx) in bodyContent" :key="idx" :item="v">
14
+ {{ v.type !== 'image' ? v.content : '' }}
15
+ </PopoverValue>
16
+ <hr v-if="footerContent.length" />
17
+ </div>
18
+ <div class="footer content">
19
+ <PopoverValue v-for="(v, idx) in footerContent" :key="idx" :item="v">
20
+ {{ v.type !== 'image' ? v.content : '' }}
21
+ </PopoverValue>
22
+ </div>
23
+ </div>
24
+ <div
25
+ class="theme-blog-popover-close"
26
+ v-show="!show && (popoverProps?.reopen ?? true) && popoverProps?.title"
27
+ @click="show = true"
28
+ >
29
+ <el-icon size="20px"><Flag /></el-icon>
30
+ </div>
31
+ </template>
32
+
33
+ <script lang="ts" setup>
34
+ import { ElIcon, ElButton } from 'element-plus'
35
+ import { Flag, CircleCloseFilled } from '@element-plus/icons-vue'
36
+ import { computed, onMounted, ref, h } from 'vue'
37
+ import type { BlogPopover } from '@sugarat/theme'
38
+ import { parseStringStyle } from '@vue/shared'
39
+ import { useBlogConfig } from '../composables/config/blog'
40
+
41
+ const { popover: popoverProps } = useBlogConfig()
42
+
43
+ const show = ref(false)
44
+
45
+ const bodyContent = computed(() => {
46
+ return popoverProps?.body || []
47
+ })
48
+
49
+ const footerContent = computed(() => {
50
+ return popoverProps?.footer || []
51
+ })
52
+
53
+ onMounted(() => {
54
+ if (!popoverProps?.title) {
55
+ return
56
+ }
57
+
58
+ const storageKey = 'theme-blog-popover'
59
+ // 取旧值
60
+ const oldValue = localStorage.getItem(storageKey)
61
+ const newValue = JSON.stringify(popoverProps)
62
+ localStorage.setItem(storageKey, newValue)
63
+
64
+ // >= 0 每次都展示,区别是否自动消失
65
+ if (Number(popoverProps?.duration ?? '') >= 0) {
66
+ show.value = true
67
+ if (popoverProps?.duration) {
68
+ setTimeout(() => {
69
+ show.value = false
70
+ }, popoverProps?.duration)
71
+ }
72
+ }
73
+
74
+ if (oldValue !== newValue && popoverProps?.duration === -1) {
75
+ // 当做新值处理
76
+ show.value = true
77
+ }
78
+ })
79
+
80
+ const PopoverValue = (
81
+ props: { key: number; item: BlogPopover.Value },
82
+ { slots }: any
83
+ ) => {
84
+ const { key, item } = props
85
+ if (item.type === 'title') {
86
+ return h(
87
+ 'h4',
88
+ {
89
+ style: parseStringStyle(item.style || '')
90
+ },
91
+ item.content
92
+ )
93
+ }
94
+ if (item.type === 'text') {
95
+ return h(
96
+ 'p',
97
+ {
98
+ style: parseStringStyle(item.style || '')
99
+ },
100
+ item.content
101
+ )
102
+ }
103
+ if (item.type === 'image') {
104
+ return h('img', {
105
+ src: item.src,
106
+ style: parseStringStyle(item.style || '')
107
+ })
108
+ }
109
+ if (item.type === 'button') {
110
+ return h(
111
+ ElButton,
112
+ {
113
+ type: 'primary',
114
+ onClick: () => {
115
+ window.open(item.link, '_self')
116
+ },
117
+ style: parseStringStyle(item.style || ''),
118
+ ...item.props
119
+ },
120
+ slots
121
+ )
122
+ }
123
+ return h(
124
+ 'div',
125
+ {
126
+ key
127
+ },
128
+ ''
129
+ )
130
+ }
131
+ </script>
132
+
133
+ <style lang="scss" scoped>
134
+ .theme-blog-popover {
135
+ width: 258px;
136
+ position: fixed;
137
+ top: 80px;
138
+ right: 20px;
139
+ z-index: 19;
140
+ box-sizing: border-box;
141
+ border: 1px solid var(--el-color-primary-light-3);
142
+ border-radius: 6px;
143
+ background-color: rgba(var(--bg-gradient-home));
144
+ box-shadow: var(--box-shadow);
145
+ }
146
+ @media screen and (min-width: 760px) and (max-width: 1140px) {
147
+ .theme-blog-popover {
148
+ top: 200px;
149
+ }
150
+ }
151
+ .header {
152
+ background-color: var(--el-color-primary-light-3);
153
+ color: #fff;
154
+ padding: 6px 4px;
155
+ display: flex;
156
+ justify-content: space-between;
157
+ align-items: center;
158
+ .close-icon {
159
+ cursor: pointer;
160
+ }
161
+ }
162
+
163
+ .title-wrapper {
164
+ display: flex;
165
+ align-items: center;
166
+ .title {
167
+ font-size: 14px;
168
+ padding-left: 6px;
169
+ }
170
+ }
171
+
172
+ .body {
173
+ box-sizing: border-box;
174
+ padding: 10px 10px 0;
175
+ hr {
176
+ border: none;
177
+ border-bottom: 1px solid #eaecef;
178
+ }
179
+ }
180
+ .footer {
181
+ box-sizing: border-box;
182
+ padding: 10px;
183
+ }
184
+
185
+ .body.content,
186
+ .footer.content {
187
+ text-align: center;
188
+ h4 {
189
+ text-align: center;
190
+ font-size: 12px;
191
+ }
192
+ p {
193
+ text-align: center;
194
+ padding: 10px 0;
195
+ font-size: 14px;
196
+ }
197
+ img {
198
+ width: 100%;
199
+ }
200
+ }
201
+
202
+ .theme-blog-popover-close {
203
+ cursor: pointer;
204
+ opacity: 0.5;
205
+ position: fixed;
206
+ z-index: 19;
207
+ top: 80px;
208
+ right: 10px;
209
+ position: fixed;
210
+ background-color: var(--el-color-primary-light-3);
211
+ padding: 8px;
212
+ color: #fff;
213
+ font-size: 12px;
214
+ border-radius: 50%;
215
+ display: flex;
216
+ flex-direction: column;
217
+ }
218
+ </style>
@@ -0,0 +1,173 @@
1
+ <template>
2
+ <div class="card recommend" v-if="recommendList.length || emptyText">
3
+ <!-- 头部 -->
4
+ <div class="card-header">
5
+ <span class="title">{{ title }}</span>
6
+ <el-button
7
+ v-if="showChangeBtn"
8
+ size="small"
9
+ type="primary"
10
+ text
11
+ @click="changePage"
12
+ >{{ nextText }}</el-button
13
+ >
14
+ </div>
15
+ <!-- 文章列表 -->
16
+ <ol class="recommend-container" v-if="currentWikiData.length">
17
+ <li v-for="(v, idx) in currentWikiData" :key="v.route">
18
+ <!-- 序号 -->
19
+ <i class="num">{{ idx + 1 }}</i>
20
+ <!-- 简介 -->
21
+ <div class="des">
22
+ <!-- title -->
23
+ <el-link type="info" class="title" :href="v.route">{{
24
+ v.meta.title
25
+ }}</el-link>
26
+ <!-- 描述信息 -->
27
+ <div class="suffix">
28
+ <!-- 日期 -->
29
+ <span class="tag">{{ formatShowDate(v.meta.date) }}</span>
30
+ </div>
31
+ </div>
32
+ </li>
33
+ </ol>
34
+ <div class="empty-text" v-else>{{ emptyText }}</div>
35
+ </div>
36
+ </template>
37
+
38
+ <script lang="ts" setup>
39
+ import { ref, computed } from 'vue'
40
+ import { ElButton, ElLink } from 'element-plus'
41
+ import { useRoute } from 'vitepress'
42
+ import { formatShowDate } from '../utils/index'
43
+ import { useArticles, useBlogConfig } from '../composables/config/blog'
44
+
45
+ const { recommend } = useBlogConfig()
46
+ const title = computed(() => recommend?.title || '🔍 相关文章')
47
+ const pageSize = computed(() => recommend?.pageSize || 9)
48
+ const nextText = computed(() => recommend?.nextText || '换一组')
49
+ const emptyText = computed(() => recommend?.empty ?? '暂无推荐文章')
50
+
51
+ const docs = useArticles()
52
+
53
+ const route = useRoute()
54
+
55
+ const recommendList = computed(() => {
56
+ const paths = route.path.split('/')
57
+ const origin = docs.value
58
+ // 过滤出公共路由前缀
59
+ // 限制为同路由前缀
60
+ .filter(
61
+ (v) =>
62
+ v.route.split('/').length === paths.length &&
63
+ v.route.startsWith(paths.slice(0, paths.length - 1).join('/'))
64
+ )
65
+ // 过滤出带标题的
66
+ .filter((v) => !!v.meta.title)
67
+ // 过滤掉自己
68
+ .filter((v) => v.route !== route.path.replace(/.html$/, ''))
69
+
70
+ origin.sort((a, b) => +new Date(b.meta.date) - +new Date(a.meta.date))
71
+ return origin
72
+ })
73
+ const currentPage = ref(1)
74
+ const changePage = () => {
75
+ const newIdx =
76
+ currentPage.value % Math.ceil(recommendList.value.length / pageSize.value)
77
+ currentPage.value = newIdx + 1
78
+ }
79
+
80
+ const currentWikiData = computed(() => {
81
+ const startIdx = (currentPage.value - 1) * pageSize.value
82
+ const endIdx = startIdx + pageSize.value
83
+ return recommendList.value.slice(startIdx, endIdx)
84
+ })
85
+
86
+ const showChangeBtn = computed(() => {
87
+ return recommendList.value.length > pageSize.value
88
+ })
89
+ </script>
90
+
91
+ <style lang="scss" scoped>
92
+ .card {
93
+ position: relative;
94
+ margin: 0 auto 10px;
95
+ padding: 10px;
96
+ width: 100%;
97
+ overflow: hidden;
98
+ border-radius: 0.25rem;
99
+ box-shadow: var(--box-shadow);
100
+ box-sizing: border-box;
101
+ transition: all 0.3s;
102
+ background-color: rgba(var(--bg-gradient));
103
+ display: flex;
104
+
105
+ &:hover {
106
+ box-shadow: var(--box-shadow-hover);
107
+ }
108
+ }
109
+
110
+ .recommend {
111
+ flex-direction: column;
112
+ padding: 16px 10px;
113
+ }
114
+
115
+ .recommend-container {
116
+ display: flex;
117
+ flex-direction: column;
118
+ list-style: none;
119
+ margin: 0;
120
+ padding: 0 10px 0 0px;
121
+ width: 100%;
122
+
123
+ li {
124
+ display: flex;
125
+
126
+ .num {
127
+ display: block;
128
+ font-size: 14px;
129
+ color: var(--description-font-color);
130
+ font-weight: 600;
131
+ margin: 6px 12px 10px 0;
132
+ width: 18px;
133
+ height: 18px;
134
+ line-height: 18px;
135
+ text-align: center;
136
+ }
137
+
138
+ .des {
139
+ overflow: hidden;
140
+ text-overflow: ellipsis;
141
+ white-space: nowrap;
142
+ }
143
+
144
+ .title {
145
+ font-size: 14px;
146
+ color: var(--vp-c-text-1);
147
+ word-break: break-all;
148
+ white-space: break-spaces;
149
+ }
150
+
151
+ .suffix {
152
+ font-size: 12px;
153
+ color: var(--vp-c-text-2);
154
+ }
155
+ }
156
+ }
157
+
158
+ .card-header {
159
+ display: flex;
160
+ width: 100%;
161
+ justify-content: space-between;
162
+ align-items: center;
163
+ margin-bottom: 10px;
164
+ .title {
165
+ font-size: 16px;
166
+ }
167
+ }
168
+ .empty-text {
169
+ padding: 6px;
170
+ font-size: 14px;
171
+ text-align: center;
172
+ }
173
+ </style>
@@ -0,0 +1,198 @@
1
+ <template>
2
+ <div class="blog-search" v-if="openSearch">
3
+ <div class="nav-search-btn-wait" @click="searchModal = true">
4
+ <el-icon size="22px">
5
+ <Search />
6
+ </el-icon>
7
+ <span class="search-tip">搜索</span>
8
+ </div>
9
+ <el-dialog
10
+ class="search-dialog"
11
+ :fullscreen="isFullScreen"
12
+ append-to-body
13
+ modal
14
+ v-model="searchModal"
15
+ width="500px"
16
+ align-center
17
+ >
18
+ <template #header>
19
+ <el-input
20
+ ref="searchInput"
21
+ autofocus
22
+ size="large"
23
+ v-model="searchWords"
24
+ class="w-50 m-2"
25
+ placeholder="Search Docs"
26
+ :prefix-icon="Search"
27
+ />
28
+ </template>
29
+ <el-empty v-if="!searchResult.length" description="No Result" />
30
+ <ul v-else>
31
+ <span>共:{{ searchResult.length }}个搜索结果</span>
32
+ <el-button
33
+ v-if="showNextResult"
34
+ type="primary"
35
+ size="small"
36
+ class="nextPage"
37
+ @click="handleNext"
38
+ >
39
+ 换一组</el-button
40
+ >
41
+ <li v-for="item in showSearchResult" :key="item.route">
42
+ <el-card body-style="padding:10px;" shadow="hover">
43
+ <a :href="item.route" @click="searchModal = false">
44
+ <div class="title">
45
+ <span>{{ item.meta.title }}</span>
46
+ <span class="date">
47
+ {{ formatDate(item.meta.date, 'yyyy-MM-dd') }}</span
48
+ >
49
+ </div>
50
+ <div class="des">
51
+ {{ item.meta.description }}
52
+ </div>
53
+ </a>
54
+ </el-card>
55
+ </li>
56
+ </ul>
57
+ </el-dialog>
58
+ </div>
59
+ </template>
60
+
61
+ <script lang="ts" setup>
62
+ import { computed, ref, watch } from 'vue'
63
+ import { Search } from '@element-plus/icons-vue'
64
+ import {
65
+ ElInput,
66
+ ElEmpty,
67
+ ElIcon,
68
+ ElDialog,
69
+ InputInstance,
70
+ ElCard,
71
+ ElButton
72
+ } from 'element-plus'
73
+ import { useWindowSize } from '@vueuse/core'
74
+ import { formatDate } from '../utils'
75
+ import { useArticles, useBlogConfig } from '../composables/config/blog'
76
+
77
+ const { search: openSearch = true } = useBlogConfig()
78
+
79
+ const searchModal = ref(false)
80
+
81
+ const { width } = useWindowSize()
82
+ const isFullScreen = computed(() => width.value < 500)
83
+
84
+ const searchWords = ref('')
85
+ const searchInput = ref<InputInstance>()
86
+ watch(
87
+ () => searchModal.value,
88
+ () => {
89
+ if (searchModal.value) {
90
+ setTimeout(() => {
91
+ searchInput.value?.focus()
92
+ })
93
+ }
94
+ }
95
+ )
96
+
97
+ const docs = useArticles()
98
+
99
+ const searchResult = computed(() => {
100
+ if (!searchWords.value) return []
101
+ const result = docs.value.filter((v) =>
102
+ `${v.meta.description}${v.meta.title}`.includes(searchWords.value)
103
+ )
104
+ result.sort((a, b) => {
105
+ return +new Date(b.meta.date) - +new Date(a.meta.date)
106
+ })
107
+ return result
108
+ })
109
+ const pageSize = ref(6)
110
+ const currentPage = ref(0)
111
+ const showSearchResult = computed(() => {
112
+ // 合法性处理
113
+ const pageIdx =
114
+ currentPage.value % Math.ceil(searchResult.value.length / pageSize.value)
115
+ const startIdx = pageIdx * pageSize.value
116
+ return searchResult.value.slice(startIdx, startIdx + pageSize.value)
117
+ })
118
+ const showNextResult = computed(
119
+ () => searchResult.value.length > pageSize.value
120
+ )
121
+ const handleNext = () => {
122
+ currentPage.value =
123
+ (currentPage.value + 1) %
124
+ Math.ceil(searchResult.value.length / pageSize.value)
125
+ }
126
+ </script>
127
+
128
+ <style lang="scss" scoped>
129
+ .blog-search {
130
+ flex: 1;
131
+ margin-left: 10px;
132
+
133
+ .nav-search-btn-wait {
134
+ cursor: pointer;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ padding: 6px;
139
+ box-sizing: border-box;
140
+ width: 100px;
141
+
142
+ &:hover {
143
+ border: 2px solid #409eff;
144
+ border-radius: 20px;
145
+ }
146
+
147
+ .search-tip {
148
+ color: #909399;
149
+ font-size: 14px;
150
+ padding-left: 10px;
151
+ }
152
+ }
153
+ }
154
+ </style>
155
+
156
+ <style lang="scss">
157
+ .search-dialog {
158
+ .el-empty {
159
+ padding: 0;
160
+ }
161
+
162
+ .el-empty__image {
163
+ width: 100px;
164
+ }
165
+ ul {
166
+ position: relative;
167
+ }
168
+ li {
169
+ margin-bottom: 10px;
170
+ font-size: 12px;
171
+ }
172
+
173
+ li .title {
174
+ display: flex;
175
+ justify-content: space-between;
176
+ }
177
+
178
+ li .des {
179
+ text-overflow: ellipsis;
180
+ overflow: hidden;
181
+ word-break: keep-all;
182
+ white-space: nowrap;
183
+ color: var(--el-color-info-dark-2);
184
+ }
185
+
186
+ li .date {
187
+ color: var(--el-color-info-light-3);
188
+ min-width: 80px;
189
+ }
190
+
191
+ .nextPage {
192
+ position: absolute;
193
+ left: 50%;
194
+ transform: translateX(-50%);
195
+ top: -30px;
196
+ }
197
+ }
198
+ </style>
@@ -0,0 +1,19 @@
1
+ <template>
2
+ <div class="sidebar"><BlogRecommendArticle /></div>
3
+ </template>
4
+
5
+ <script lang="ts" setup>
6
+ import BlogRecommendArticle from './BlogRecommendArticle.vue'
7
+ </script>
8
+
9
+ <style lang="scss" scoped>
10
+ .sidebar {
11
+ margin-top: 40px;
12
+ }
13
+
14
+ @media screen and (min-width: 960px) and (max-width: 1120px) {
15
+ .sidebar {
16
+ margin-top: 60px;
17
+ }
18
+ }
19
+ </style>
@@ -0,0 +1,96 @@
1
+ import { useData, useRoute } from 'vitepress'
2
+ import {
3
+ Component,
4
+ computed,
5
+ defineComponent,
6
+ h,
7
+ inject,
8
+ InjectionKey,
9
+ provide,
10
+ ref,
11
+ Ref
12
+ } from 'vue'
13
+ import type { Theme } from './index'
14
+
15
+ const configSymbol: InjectionKey<Ref<Theme.Config>> = Symbol('theme-config')
16
+
17
+ const activeTagSymbol: InjectionKey<Ref<Theme.activeTag>> = Symbol('active-tag')
18
+
19
+ const homeConfigSymbol: InjectionKey<Theme.HomeConfig> = Symbol('home-config')
20
+
21
+ export function withConfigProvider(App: Component) {
22
+ return defineComponent({
23
+ name: 'ConfigProvider',
24
+ props: {
25
+ handleChangeSlogan: {
26
+ type: Function,
27
+ required: false
28
+ }
29
+ },
30
+ setup(props, { slots }) {
31
+ provide(homeConfigSymbol, props as Theme.HomeConfig)
32
+
33
+ const { theme } = useData()
34
+ const config = computed(() => resolveConfig(theme.value))
35
+ provide(configSymbol, config)
36
+
37
+ const activeTag = ref<Theme.activeTag>({
38
+ label: '',
39
+ type: ''
40
+ })
41
+ provide(activeTagSymbol, activeTag)
42
+ return () => h(App, null, slots)
43
+ }
44
+ })
45
+ }
46
+
47
+ export function useConfig() {
48
+ return {
49
+ config: inject(configSymbol)!.value
50
+ }
51
+ }
52
+
53
+ export function useBlogConfig() {
54
+ return inject(configSymbol)!.value.blog
55
+ }
56
+
57
+ export function useHomeConfig() {
58
+ return inject(homeConfigSymbol)!
59
+ }
60
+
61
+ export function useGiscusConfig() {
62
+ const blogConfig = useConfig()
63
+ return blogConfig.config.blog.comment
64
+ }
65
+
66
+ export function useArticles() {
67
+ const blogConfig = useConfig()
68
+ const articles = computed(() => blogConfig.config?.blog?.pagesData || [])
69
+ return articles
70
+ }
71
+
72
+ export function useActiveTag() {
73
+ return inject(activeTagSymbol)!
74
+ }
75
+
76
+ export function useCurrentArticle() {
77
+ const blogConfig = useConfig()
78
+ const route = useRoute()
79
+
80
+ const docs = computed(() => blogConfig.config.blog.pagesData)
81
+ const currentArticle = computed(() =>
82
+ docs.value.find((v) => v.route === route.path.replace(/.html$/, ''))
83
+ )
84
+
85
+ return currentArticle
86
+ }
87
+
88
+ function resolveConfig(config: Theme.Config): Theme.Config {
89
+ return {
90
+ ...config,
91
+ blog: {
92
+ ...config?.blog,
93
+ pagesData: config?.blog?.pagesData || []
94
+ }
95
+ }
96
+ }