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,53 @@
1
+ <script setup lang="ts">
2
+ import { Temporal } from 'temporal-polyfill'
3
+
4
+ const props = defineProps<{
5
+ datetime?: string
6
+ rotate?: boolean
7
+ }>()
8
+
9
+ const emojiStatic = ['🕛', '🕧', '🕐', '🕜', '🕑', '🕝', '🕒', '🕞', '🕓', '🕟', '🕔', '🕠', '🕕', '🕡', '🕖', '🕢', '🕗', '🕣', '🕘', '🕤', '🕙', '🕥', '🕚', '🕦']
10
+ const emojiRotate = ['🕛', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚']
11
+
12
+ const now = ref(Temporal.Now.zonedDateTimeISO())
13
+
14
+ const datetime = computed(() => props.datetime
15
+ ? toZonedTemporal(props.datetime)
16
+ : now.value)
17
+
18
+ const status = computed(() => {
19
+ const { hour, minute } = datetime.value
20
+
21
+ if (!props.rotate) {
22
+ const emojiIndex = (hour * 2 + Math.round(minute / 30)) % emojiStatic.length
23
+ return { emoji: emojiStatic[emojiIndex] }
24
+ }
25
+
26
+ const minuteAt = Math.round(minute / 5)
27
+ const emojiIndex = (hour % 12 - minuteAt + emojiRotate.length) % emojiRotate.length
28
+ return { rotate: minuteAt * 30, emoji: emojiRotate[emojiIndex] }
29
+ })
30
+
31
+ // 定时器只能在客户端运行,否则 nuxt generate 不能自动退出
32
+ const { resume } = useIntervalFn(() => {
33
+ now.value = Temporal.Now.zonedDateTimeISO()
34
+ }, 30000, { immediate: false })
35
+
36
+ whenever(() => !props.datetime, resume)
37
+ </script>
38
+
39
+ <template>
40
+ <span
41
+ class="emoji-clock"
42
+ :class="{ rotate }"
43
+ :style="{ '--deg': rotate ? `${status.rotate}deg` : undefined }"
44
+ v-text="status.emoji"
45
+ />
46
+ </template>
47
+
48
+ <style lang="scss" scoped>
49
+ .emoji-clock.rotate {
50
+ display: inline-block;
51
+ transform: rotate(var(--deg, 0deg));
52
+ }
53
+ </style>
@@ -0,0 +1,212 @@
1
+ <script setup lang="ts">
2
+ import type { CSSProperties } from 'vue'
3
+ import type { FeedEntry } from '../../types/feed'
4
+ // eslint-disable-next-line unused-imports/no-unused-imports
5
+ import { Temporal } from 'temporal-polyfill'
6
+
7
+ const props = defineProps<FeedEntry>()
8
+
9
+ const clarity = useClarityConfig()
10
+ const route = useRoute()
11
+ const isInspect = computed(() => import.meta.dev && route.query.inspect !== undefined)
12
+
13
+ const title = computed(() => props.title ?? props.sitenick ?? props.author)
14
+ const domainTip = computed(() => getDomainType(getMainDomain(props.link, true)))
15
+ const domainIcon = computed(() => getDomainIcon(props.link))
16
+
17
+ function getInspectStyle(src: string): CSSProperties {
18
+ src = getMainDomain(src)
19
+ let color = 'red'
20
+
21
+ if (src === getMainDomain(props.link))
22
+ color = 'transparent' // 来自源站
23
+ else if (src === 'gstatic.cn' || src === 'webp.se')
24
+ color = 'yellow' // 来自API获取
25
+ else if (src === 'qlogo.cn')
26
+ color = 'lightblue' // 来自QQ头像
27
+ else if (src === ('wsrv.nl'))
28
+ color = 'lightgreen' // 来自GitHub头像
29
+
30
+ return {
31
+ border: `2px solid ${color}`,
32
+ boxSizing: 'content-box',
33
+ }
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <Tooltip :delay="200" interactive ^role="link" hide-on-click="toggle">
39
+ <UtilLink
40
+ class="feed-card gradient-card"
41
+ :to="error ? undefined : link"
42
+ rel="noopener"
43
+ :data-error="error"
44
+ >
45
+ <div class="avatar" :title="feed ? undefined : '无订阅源'">
46
+ <ClientOnly v-if="isInspect">
47
+ <span style="position: absolute; left: 100%; white-space: nowrap;" v-text="title" />
48
+ <NuxtImg :src="icon" :title="icon" :style="getInspectStyle(icon)" />
49
+ <NuxtImg :src="avatar" :title="avatar" :style="getInspectStyle(avatar)" />
50
+ </ClientOnly>
51
+
52
+ <NuxtImg v-else class="round-cobblestone" :src="avatar" :alt="author" loading="lazy" />
53
+ <Icon v-if="clarity.link.remindNoFeed && !feed" class="no-feed" name="tabler:bell-off" />
54
+ </div>
55
+
56
+ <span class="author">{{ author }}</span>
57
+ <span class="sitenick">{{ sitenick }}</span>
58
+ </UtilLink>
59
+
60
+ <template #content>
61
+ <div class="site-content">
62
+ <NuxtImg class="site-icon" :src="icon" :alt="title" loading="lazy" />
63
+
64
+ <div class="site-info">
65
+ <h3 class="text-creative">
66
+ {{ title }}
67
+ </h3>
68
+
69
+ <code class="domain" :title="domainTip">
70
+ <span>{{ getDomain(link) }}</span>
71
+ <Icon v-if="domainIcon" class="domain-mark" :name="domainIcon" />
72
+ </code>
73
+ </div>
74
+
75
+ <Icon
76
+ v-for="arch in archs" :key="arch"
77
+ class="arch" :name="getArchIcon(arch)" :title="arch"
78
+ />
79
+ </div>
80
+ <div class="desc-content">
81
+ <div class="date">
82
+ {{ Temporal.PlainDate.from(date).toLocaleString() }}
83
+ </div>
84
+
85
+ <p>{{ error ?? desc }}</p>
86
+
87
+ <p v-if="comment">
88
+ <Icon name="tabler:message-dots" /> {{ comment }}
89
+ </p>
90
+ </div>
91
+ </template>
92
+ </Tooltip>
93
+ </template>
94
+
95
+ <style lang="scss" scoped>
96
+ .feed-card {
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 0.2em;
100
+ width: 14em;
101
+ margin: 1em auto;
102
+ padding: 0.5em;
103
+ line-height: 1.4;
104
+ transition: transform 0.2s;
105
+ animation: float-in 0.2s var(--delay) backwards;
106
+
107
+ &:hover {
108
+ transform: translateY(-2px);
109
+ }
110
+
111
+ &[data-error] {
112
+ opacity: 0.5;
113
+ cursor: not-allowed;
114
+ }
115
+
116
+ .avatar {
117
+ position: relative;
118
+ margin: 0 0.5em 0 0;
119
+
120
+ img {
121
+ display: block;
122
+ width: 2.5rem;
123
+ height: 2.5rem;
124
+ border-radius: 50%;
125
+ box-shadow: var(--box-shadow-2);
126
+ background-color: var(--ld-bg-card);
127
+ object-fit: cover;
128
+ }
129
+
130
+ .no-feed {
131
+ position: absolute;
132
+ inset-inline-end: -0.5em;
133
+ bottom: 0;
134
+ }
135
+ }
136
+
137
+ .author {
138
+ overflow: hidden; // 长词折行
139
+ }
140
+
141
+ .sitenick {
142
+ opacity: 0.4;
143
+ font-size: 0.8em;
144
+ }
145
+
146
+ .no-feed {
147
+ opacity: 0.6;
148
+ font-size: 0.8em;
149
+ }
150
+ }
151
+
152
+ // https://vue-tippy.netlify.app/props#appendto
153
+ // Tooltip 位于组件根部时,interactive tippy 会插入到父组件
154
+ :deep() ~ [data-tippy-root] > .tippy-box {
155
+ overflow: hidden;
156
+ overflow: clip; // 需保留气泡箭头
157
+ padding: 0;
158
+
159
+ &[data-placement="top"] > .tippy-svg-arrow {
160
+ fill: var(--c-bg-1);
161
+ }
162
+ }
163
+
164
+ .site-content {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 0.5em;
168
+ padding: 0.5em 1em;
169
+
170
+ .site-icon {
171
+ width: 1.5rem;
172
+ height: 1.5rem;
173
+ border-radius: 4px;
174
+ object-fit: contain;
175
+ }
176
+
177
+ .site-info {
178
+ flex: 1;
179
+ margin-inline-end: 0.5em;
180
+
181
+ .domain {
182
+ font-size: 0.9em;
183
+ }
184
+
185
+ .domain-mark {
186
+ font-size: 0.8em;
187
+ vertical-align: super;
188
+ }
189
+ }
190
+ }
191
+
192
+ .desc-content {
193
+ position: relative;
194
+ padding: 0.5em 1em;
195
+ background-color: var(--c-bg-1);
196
+
197
+ p + p {
198
+ margin-top: 0.5em;
199
+ }
200
+
201
+ .date {
202
+ position: absolute;
203
+ opacity: 0.1;
204
+ inset-inline-end: -0.1em;
205
+ bottom: -0.3em;
206
+ font-size: 3em;
207
+ font-weight: bold;
208
+ white-space: nowrap;
209
+ pointer-events: none;
210
+ }
211
+ }
212
+ </style>
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ import type { FeedEntry, FeedGroup } from '../../types/feed'
3
+ import { shuffle } from 'es-toolkit/array'
4
+
5
+ const props = defineProps<FeedGroup & { shuffle?: boolean }>()
6
+ const route = useRoute()
7
+ const entries = ref(props.entries)
8
+
9
+ // 友链浮现随机延迟
10
+ function getCardDelay(feed: FeedEntry) {
11
+ let hash = 0
12
+ for (const char of feed.link) {
13
+ hash = hash * 31 + char.charCodeAt(0)
14
+ }
15
+ return (hash % 1000) / 1000
16
+ }
17
+
18
+ const shuffleEntries = () => entries.value = shuffle(entries.value)
19
+ const unshuffleEntries = () => entries.value = props.entries
20
+
21
+ onMounted(() => {
22
+ if (props.shuffle && route.query.shuffle !== 'false')
23
+ shuffleEntries()
24
+ })
25
+
26
+ if (import.meta.dev) {
27
+ watch(() => props.entries, (newEntries) => {
28
+ entries.value = newEntries
29
+ })
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <section class="feed-group">
35
+ <h3 class="feed-title">
36
+ <button
37
+ v-if="props.shuffle"
38
+ role="button"
39
+ title="点击随机排序,按住修饰键点击可取消随机排序"
40
+ @click="unshuffleEntries"
41
+ @click.exact="shuffleEntries"
42
+ v-text="name"
43
+ />
44
+ <span v-else v-text="name" />
45
+ </h3>
46
+ <p class="feed-desc" v-text="desc" />
47
+
48
+ <TransitionGroup tag="menu" class="feed-list" name="float-in">
49
+ <li
50
+ v-for="entry in entries"
51
+ :key="entry.link"
52
+ :style="`--delay: ${getCardDelay(entry)}s;`"
53
+ >
54
+ <FeedCard v-bind="entry" />
55
+ </li>
56
+ </TransitionGroup>
57
+ </section>
58
+ </template>
59
+
60
+ <style lang="scss" scoped>
61
+ .feed-group {
62
+ container-type: inline-size;
63
+ margin: 2em 1em;
64
+ }
65
+
66
+ .feed-title {
67
+ contain: layout; // 极窄宽度阻止滚动条
68
+ position: sticky;
69
+ opacity: 0.5;
70
+ top: 0;
71
+ margin-bottom: -0.3em;
72
+ mask-image: linear-gradient(#FFF 50%, transparent);
73
+ font-family: var(--font-stroke-free);
74
+ font-size: 5em;
75
+ font-weight: 800;
76
+ line-height: 1;
77
+ text-align: center;
78
+ color: transparent;
79
+ transition: color 0.2s;
80
+ -webkit-text-stroke: 1px var(--c-text-3);
81
+
82
+ &::selection, :hover > & {
83
+ color: var(--c-text-3);
84
+ }
85
+ }
86
+
87
+ .feed-desc {
88
+ text-align: center;
89
+ color: var(--c-text-2);
90
+ }
91
+
92
+ .feed-list {
93
+ display: grid;
94
+ grid-template-columns: repeat(auto-fill, minmax(12em, 1fr));
95
+ gap: 0.2em 0.5em;
96
+ margin: 1em auto;
97
+
98
+ @mixin feed-narrow {
99
+ grid-template-columns: repeat(auto-fill, minmax(5em, 1fr));
100
+ font-size: 0.9em;
101
+
102
+ :deep(.feed-card) {
103
+ flex-direction: column;
104
+ text-align: center;
105
+
106
+ .avatar.avatar {
107
+ margin: 0 0 0.2em;
108
+ }
109
+ }
110
+ }
111
+
112
+ @media (max-width: $breakpoint-phone) {
113
+ @include feed-narrow;
114
+ }
115
+
116
+ @container (max-width: #{$breakpoint-phone}) {
117
+ @include feed-narrow;
118
+ }
119
+ }
120
+
121
+ :deep(.feed-card.feed-card) {
122
+ width: auto;
123
+ margin: 0;
124
+ }
125
+
126
+ .float-in-move {
127
+ contain: paint; // 防止移动时出现滚动条
128
+ pointer-events: none; // 阻止触发错误定位的气泡
129
+ }
130
+ </style>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ title?: string
4
+ }>()
5
+ </script>
6
+
7
+ <template>
8
+ <details>
9
+ <summary>
10
+ <slot name="title">
11
+ {{ title }}
12
+ </slot>
13
+ </summary>
14
+ <slot />
15
+ </details>
16
+ </template>
17
+
18
+ <style lang="scss" scoped>
19
+ details {
20
+ margin: 1em 0;
21
+ padding: 0.5em 0.8em;
22
+ border: 1px solid var(--c-border);
23
+ border-radius: 0.5em;
24
+ background-color: var(--c-bg-2);
25
+ font-size: 0.9em;
26
+
27
+ &[open] {
28
+ > summary {
29
+ margin-bottom: 0.5em;
30
+ font-weight: bold;
31
+ color: currentcolor;
32
+
33
+ &::before {
34
+ content: "收起";
35
+ }
36
+ }
37
+ }
38
+
39
+ > summary {
40
+ margin: -0.5em -0.8em;
41
+ padding: 0.5em 0.8em;
42
+ color: var(--c-text-2);
43
+ transition: all 0.2s;
44
+ cursor: pointer;
45
+
46
+ > :deep(p) {
47
+ display: inline;
48
+ }
49
+
50
+ &::before {
51
+ content: "展开";
52
+ float: right;
53
+ float: inline-end;
54
+ opacity: 0.5;
55
+ margin-inline-start: 0.5em;
56
+ font-weight: normal;
57
+ transition: color 0.2s;
58
+ }
59
+
60
+ &:hover {
61
+ color: var(--c-text);
62
+ }
63
+ }
64
+
65
+ > :deep(.z-codeblock) {
66
+ margin: 0 -0.8em -0.5em;
67
+ }
68
+ }
69
+ </style>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { useEventListener } from '@vueuse/core'
3
+ import { computed, ref } from 'vue'
4
+
5
+ const props = withDefaults(defineProps<{
6
+ text?: string
7
+ /** https://developer.mozilla.org/zh-CN/docs/Web/API/KeyboardEvent/key */
8
+ code?: string
9
+ /** 仅 macOS 默认显示图标 */
10
+ icon?: boolean
11
+ ctrl?: boolean
12
+ shift?: boolean
13
+ alt?: boolean
14
+ meta?: boolean
15
+ win?: boolean
16
+ /** 智能适配:Windows用Ctrl,macOS用Cmd */
17
+ cmd?: boolean
18
+ prevent?: boolean
19
+ }>(), {
20
+ icon: undefined,
21
+ })
22
+
23
+ const emit = defineEmits<{
24
+ press: []
25
+ }>()
26
+
27
+ const mounted = useMounted()
28
+ const isMac = computed(() => mounted.value && /mac ?os/i.test(navigator?.userAgent))
29
+ const useSymbol = computed(() => isMac.value ? props.icon !== false : props.icon)
30
+ const keyJoiner = computed(() => useSymbol.value ? '' : '+')
31
+
32
+ // @keep-sorted
33
+ const displayMap = {
34
+ ' ': 'Space',
35
+ 'ArrowDown': '↓',
36
+ 'ArrowLeft': '←',
37
+ 'ArrowRight': '→',
38
+ 'ArrowUp': '↑',
39
+ 'Control': 'Ctrl',
40
+ 'Delete': 'Del',
41
+ 'Escape': 'Esc',
42
+ get 'Meta'() { return isMac.value ? 'Cmd' : 'Win' },
43
+ }
44
+
45
+ // @keep-sorted
46
+ const symbolMap = {
47
+ ' ': '␣',
48
+ 'Alt': '⌥',
49
+ 'Backspace': '⌫',
50
+ 'Control': '⌃',
51
+ 'Delete': '⌦',
52
+ 'Enter': '↵',
53
+ 'Escape': '⎋',
54
+ get 'Meta'() { return isMac.value ? '⌘' : '⊞' },
55
+ 'Shift': '⇧',
56
+ 'Tab': '⇥',
57
+ 'Win': '⊞',
58
+ }
59
+
60
+ function normalizeCodeDisplay(code?: string) {
61
+ if (!code)
62
+ return ''
63
+ if (useSymbol.value && code in symbolMap)
64
+ return symbolMap[code as keyof typeof symbolMap]
65
+ if (code in displayMap)
66
+ return displayMap[code as keyof typeof displayMap]
67
+ return code
68
+ }
69
+
70
+ const codeDisplay = computed(() => {
71
+ if (props.text)
72
+ return props.text
73
+
74
+ const keyConfigs = [
75
+ { condition: props.cmd, code: isMac.value ? 'Meta' : 'Control' },
76
+ { condition: props.ctrl && !props.cmd, code: 'Control' },
77
+ { condition: props.shift, code: 'Shift' },
78
+ { condition: props.alt, code: 'Alt' },
79
+ { condition: props.meta && !props.cmd, code: 'Meta' },
80
+ { condition: props.win && !props.meta, code: 'Win' },
81
+ { condition: props.code, code: props.code },
82
+ ]
83
+
84
+ return keyConfigs
85
+ .filter(config => config.condition)
86
+ .map(config => normalizeCodeDisplay(config.code))
87
+ .join(keyJoiner.value)
88
+ })
89
+
90
+ const active = ref(false)
91
+
92
+ const ctrlState = useKeyModifier('Control')
93
+ const shiftState = useKeyModifier('Shift')
94
+ const altState = useKeyModifier('Alt')
95
+ const metaState = useKeyModifier('Meta')
96
+
97
+ /** 检查当前修饰键状态是否匹配 props */
98
+ function modifiersMatch() {
99
+ const cmdMatch = props.cmd
100
+ ? (isMac.value ? metaState.value : ctrlState.value)
101
+ : true
102
+
103
+ return cmdMatch
104
+ && (!props.ctrl || (!props.cmd && ctrlState.value))
105
+ && (!props.shift || shiftState.value)
106
+ && (!props.alt || altState.value)
107
+ && (!props.meta || (!props.cmd && metaState.value))
108
+ }
109
+
110
+ function matchKeyEvent(e: KeyboardEvent, expectedCode?: string) {
111
+ if (expectedCode && e.key?.toLowerCase() !== expectedCode.toLowerCase())
112
+ return false
113
+ return modifiersMatch()
114
+ }
115
+
116
+ useEventListener('keydown', (e) => {
117
+ if (matchKeyEvent(e, props.code)) {
118
+ emit('press')
119
+ active.value = true
120
+ props.prevent && e.preventDefault()
121
+ }
122
+ })
123
+
124
+ useEventListener('keyup', (e) => {
125
+ if (matchKeyEvent(e, props.code) || !modifiersMatch())
126
+ active.value = false
127
+ })
128
+
129
+ useEventListener('blur', () => {
130
+ active.value = false
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <UtilHydrateSafe>
136
+ <kbd :class="{ active }" @click.stop="emit('press')">
137
+ <slot>{{ codeDisplay }}</slot>
138
+ </kbd>
139
+ </UtilHydrateSafe>
140
+ </template>
141
+
142
+ <style lang="scss" scoped>
143
+ kbd {
144
+ display: inline-block;
145
+ margin: 0.1em;
146
+ padding: 0 0.2em 0.1em;
147
+ border-radius: 0.2em;
148
+ box-shadow: inset 0 -0.15em 0 var(--c-bg-soft);
149
+ background-color: var(--c-bg-soft);
150
+ font-family: var(--font-basic);
151
+ font-size: 0.9em;
152
+ line-height: 1.4;
153
+ color: var(--c-text-2);
154
+ transition: all 0.1s;
155
+ user-select: none;
156
+
157
+ &:active, &.active {
158
+ box-shadow: inset 0 -0.1em 0 var(--c-primary);
159
+ background-color: var(--c-primary-soft);
160
+ color: var(--c-primary);
161
+ transform: translateY(0.05em);
162
+ }
163
+ }
164
+ </style>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ banner?: string
4
+ mirror?: ImgService
5
+ title: string
6
+ description?: string
7
+ link: string
8
+ }>()
9
+ </script>
10
+
11
+ <template>
12
+ <UtilLink :to="link" class="link-banner card" :title="joinWith([title, description, link])">
13
+ <UtilImg v-if="banner" class="link-banner-bg" :src="banner" :mirror />
14
+ <div class="link-banner-header" />
15
+ <div class="link-banner-info">
16
+ <div class="link-banner-title">
17
+ {{ title }}
18
+ </div>
19
+ <div class="link-banner-desc">
20
+ {{ description ?? getDomain(link) }}
21
+ </div>
22
+ </div>
23
+ </UtilLink>
24
+ </template>
25
+
26
+ <style lang="scss" scoped>
27
+ .link-banner {
28
+ line-height: 1.4;
29
+
30
+ article & {
31
+ max-width: $breakpoint-phone;
32
+ margin: 2rem auto;
33
+ }
34
+
35
+ .link-banner-bg {
36
+ display: block;
37
+ width: 100%;
38
+ aspect-ratio: 2.4;
39
+ margin-bottom: -5%;
40
+ mask-image: linear-gradient(#FFF 50%, transparent);
41
+ object-fit: cover;
42
+ }
43
+
44
+ .link-banner-info {
45
+ padding: 0.5em 1em;
46
+ }
47
+
48
+ .link-banner-title {
49
+ font-size: 1.2em;
50
+ color: var(--c-text);
51
+ }
52
+
53
+ .link-banner-desc {
54
+ margin-top: 0.5em;
55
+ font-size: 0.8em;
56
+ color: var(--c-text-2);
57
+ }
58
+ }
59
+ </style>