@mete0r/minimal-homepage 2.0.1

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,38 @@
1
+ <template>
2
+ <section class="py-16" id="about">
3
+ <div class="rounded-[2.5rem] bg-white/60 dark:bg-gray-900/60 backdrop-blur-xl border border-white/40 dark:border-gray-700/40 p-8 md:p-12 shadow-[0_16px_40px_rgba(15,23,42,0.12)] dark:shadow-[0_18px_40px_rgba(0,0,0,0.5)]">
4
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
5
+ <div class="order-2 md:order-1">
6
+ <h1 class="text-4xl md:text-5xl font-extrabold mb-4 dark:text-white">关于我</h1>
7
+ <p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed mb-4">
8
+ {{config.author.aboutMeintro}}
9
+ </p>
10
+ <p class="text-lg text-gray-700 dark:text-gray-300 leading-relaxed mb-6">
11
+ MBTI:<a :href="`https://www.16personalities.com/ch/${config.author.mbti}-人格`" class="font-bold text-blue-600 dark:text-blue-400" target="_blank" rel="noopener noreferrer">{{ config.author.mbti }}</a>
12
+ </p>
13
+ <a
14
+ :href="config.blog.url"
15
+ target="_blank"
16
+ rel="noopener noreferrer"
17
+ class="inline-block px-6 py-3 rounded-2xl bg-blue-600 text-white font-semibold hover:bg-blue-700 transition-colors"
18
+ >
19
+ 访问我的博客
20
+ </a>
21
+ </div>
22
+
23
+ <div class="order-1 md:order-2 flex justify-center md:justify-end">
24
+ <img
25
+ :src="config.author.aiPortrait"
26
+ alt="AI Portrait"
27
+ class="w-full max-w-md object-contain drop-shadow-2xl"
28
+ style="-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, black 75%, transparent 100%);"
29
+ />
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </section>
34
+ </template>
35
+
36
+ <script setup>
37
+ import config from '../../main.config.js'
38
+ </script>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <footer class="relative z-10 py-6 md:py-8 px-4 md:px-10 mt-14 md:mt-20 border-t border-gray-200 dark:border-gray-800 flex flex-col md:flex-row gap-4 md:gap-0 justify-between md:items-center text-sm text-gray-500 dark:text-gray-400">
3
+ <div class="flex flex-col gap-1">
4
+ <span>{{ config.footer.copyright }}</span>
5
+ <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 dark:hover:text-white">
6
+ {{ config.footer.icp }}
7
+ </a>
8
+ </div>
9
+
10
+ <div class="flex gap-4 items-center text-xl">
11
+ <a
12
+ v-for="link in config.footer.links"
13
+ :key="link.url"
14
+ :href="link.url"
15
+ target="_blank"
16
+ rel="noopener noreferrer"
17
+ class="hover:text-gray-900 dark:hover:text-white transition-colors inline-flex items-center justify-center w-5 h-5"
18
+ :title="link.name"
19
+ >
20
+ <template v-if="link.icon">
21
+ <img :src="link.icon" :alt="link.name" class="w-5 h-5" />
22
+ </template>
23
+ <template v-else>
24
+ <span class="text-xs leading-none">{{ link.name.substring(0, 2) }}</span>
25
+ </template>
26
+ </a>
27
+ </div>
28
+ </footer>
29
+ </template>
30
+
31
+ <script setup>
32
+ import config from '../../main.config.js'
33
+ </script>
@@ -0,0 +1,147 @@
1
+ <template>
2
+ <section class="py-14 md:py-20" id="socials">
3
+ <div class="flex justify-between items-center mb-6 md:mb-10">
4
+ <h2 class="text-2xl md:text-3xl font-bold dark:text-white flex items-center gap-2">
5
+ <span class="w-2 h-7 md:h-8 bg-blue-500 rounded-full"></span>
6
+ 社交与链接
7
+ </h2>
8
+
9
+ <div v-if="!isMobile" class="flex gap-2">
10
+ <button
11
+ @click="scroll('left')"
12
+ class="p-2.5 md:p-3 rounded-xl bg-white/50 dark:bg-gray-800/50 backdrop-blur hover:bg-white dark:text-white transition shadow-sm"
13
+ aria-label="Scroll left"
14
+ >
15
+
16
+ </button>
17
+ <button
18
+ @click="scroll('right')"
19
+ class="p-2.5 md:p-3 rounded-xl bg-white/50 dark:bg-gray-800/50 backdrop-blur hover:bg-white dark:text-white transition shadow-sm"
20
+ aria-label="Scroll right"
21
+ >
22
+
23
+ </button>
24
+ </div>
25
+ </div>
26
+
27
+ <div v-if="isMobile" class="space-y-4">
28
+ <a
29
+ v-for="item in pagedSocials"
30
+ :key="item.name"
31
+ :href="item.url"
32
+ target="_blank"
33
+ rel="noopener noreferrer"
34
+ class="block"
35
+ >
36
+ <div class="bg-white/60 dark:bg-gray-800/60 backdrop-blur-xl p-5 rounded-2xl border border-white/40 dark:border-gray-700/40 shadow-[0_10px_28px_rgba(0,0,0,0.05)] dark:shadow-[0_14px_30px_rgba(0,0,0,0.45)] transition-all duration-300 active:scale-[0.99]">
37
+ <div class="text-3xl mb-3 h-9 flex items-center">
38
+ <img
39
+ v-if="isIconUrl(item.icon)"
40
+ :src="item.icon"
41
+ :alt="item.name + ' icon'"
42
+ class="h-9 w-9 object-contain bg-transparent"
43
+ />
44
+ <span v-else>{{ item.icon }}</span>
45
+ </div>
46
+ <h3 class="font-bold dark:text-white">{{ item.name }}</h3>
47
+ <p class="text-sm text-gray-500">{{ item.label }}</p>
48
+ </div>
49
+ </a>
50
+
51
+ <div v-if="totalPages > 1" class="flex items-center justify-between pt-2">
52
+ <button
53
+ @click="prevPage"
54
+ :disabled="currentPage === 1"
55
+ class="px-3 py-1.5 rounded-lg text-sm bg-white/70 dark:bg-gray-800/70 disabled:opacity-40"
56
+ >
57
+ 上一页
58
+ </button>
59
+ <span class="text-sm text-gray-500 dark:text-gray-400">{{ currentPage }} / {{ totalPages }}</span>
60
+ <button
61
+ @click="nextPage"
62
+ :disabled="currentPage === totalPages"
63
+ class="px-3 py-1.5 rounded-lg text-sm bg-white/70 dark:bg-gray-800/70 disabled:opacity-40"
64
+ >
65
+ 下一页
66
+ </button>
67
+ </div>
68
+ </div>
69
+
70
+ <div v-else ref="container" class="flex gap-6 overflow-x-hidden scroll-smooth snap-x">
71
+ <a
72
+ v-for="item in config.socials"
73
+ :key="item.name"
74
+ :href="item.url"
75
+ target="_blank"
76
+ rel="noopener noreferrer"
77
+ class="min-w-[calc(20%-1.25rem)] snap-start group block cursor-pointer"
78
+ >
79
+ <div class="bg-white/60 dark:bg-gray-800/60 backdrop-blur-xl p-6 rounded-[2rem] border border-white/40 dark:border-gray-700/40 shadow-[0_10px_28px_rgba(0,0,0,0.05)] dark:shadow-[0_14px_30px_rgba(0,0,0,0.45)] transition-all duration-500 group-hover:-translate-y-2 group-hover:bg-white dark:group-hover:bg-gray-700">
80
+ <div class="text-4xl mb-4 h-10 flex items-center">
81
+ <img
82
+ v-if="isIconUrl(item.icon)"
83
+ :src="item.icon"
84
+ :alt="item.name + ' icon'"
85
+ class="h-10 w-10 object-contain bg-transparent"
86
+ />
87
+ <span v-else>{{ item.icon }}</span>
88
+ </div>
89
+ <h3 class="font-bold dark:text-white">{{ item.name }}</h3>
90
+ <p class="text-sm text-gray-500">{{ item.label }}</p>
91
+ </div>
92
+ </a>
93
+ </div>
94
+ </section>
95
+ </template>
96
+
97
+ <script setup>
98
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
99
+ import config from '../../main.config.js'
100
+
101
+ const container = ref(null)
102
+ const isMobile = ref(false)
103
+ const currentPage = ref(1)
104
+ const mobilePageSize = 3
105
+
106
+ const totalPages = computed(() => Math.max(1, Math.ceil((config.socials?.length || 0) / mobilePageSize)))
107
+ const pagedSocials = computed(() => {
108
+ const start = (currentPage.value - 1) * mobilePageSize
109
+ return (config.socials || []).slice(start, start + mobilePageSize)
110
+ })
111
+
112
+ const handleResize = () => {
113
+ const mobile = window.innerWidth < 768
114
+ if (mobile !== isMobile.value) {
115
+ isMobile.value = mobile
116
+ currentPage.value = 1
117
+ }
118
+ }
119
+
120
+ const prevPage = () => {
121
+ if (currentPage.value > 1) currentPage.value -= 1
122
+ }
123
+
124
+ const nextPage = () => {
125
+ if (currentPage.value < totalPages.value) currentPage.value += 1
126
+ }
127
+
128
+ const scroll = (dir) => {
129
+ if (!container.value) return
130
+ const step = container.value.offsetWidth * 0.82
131
+ container.value.scrollLeft += dir === 'right' ? step : -step
132
+ }
133
+
134
+ const isIconUrl = (icon) => {
135
+ if (typeof icon !== 'string') return false
136
+ return /^(https?:)?\/\//.test(icon) || icon.endsWith('.svg')
137
+ }
138
+
139
+ onMounted(() => {
140
+ handleResize()
141
+ window.addEventListener('resize', handleResize)
142
+ })
143
+
144
+ onUnmounted(() => {
145
+ window.removeEventListener('resize', handleResize)
146
+ })
147
+ </script>
@@ -0,0 +1,93 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import viteLogo from '../assets/vite.svg'
4
+ import heroImg from '../assets/hero.png'
5
+ import vueLogo from '../assets/vue.svg'
6
+
7
+ const count = ref(0)
8
+ </script>
9
+
10
+ <template>
11
+ <section id="center">
12
+ <div class="hero">
13
+ <img :src="heroImg" class="base" width="170" height="179" alt="" />
14
+ <img :src="vueLogo" class="framework" alt="Vue logo" />
15
+ <img :src="viteLogo" class="vite" alt="Vite logo" />
16
+ </div>
17
+ <div>
18
+ <h1>Get started</h1>
19
+ <p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
20
+ </div>
21
+ <button class="counter" @click="count++">Count is {{ count }}</button>
22
+ </section>
23
+
24
+ <div class="ticks"></div>
25
+
26
+ <section id="next-steps">
27
+ <div id="docs">
28
+ <svg class="icon" role="presentation" aria-hidden="true">
29
+ <use href="/icons.svg#documentation-icon"></use>
30
+ </svg>
31
+ <h2>Documentation</h2>
32
+ <p>Your questions, answered</p>
33
+ <ul>
34
+ <li>
35
+ <a href="https://vite.dev/" target="_blank">
36
+ <img class="logo" :src="viteLogo" alt="" />
37
+ Explore Vite
38
+ </a>
39
+ </li>
40
+ <li>
41
+ <a href="https://vuejs.org/" target="_blank">
42
+ <img class="button-icon" :src="vueLogo" alt="" />
43
+ Learn more
44
+ </a>
45
+ </li>
46
+ </ul>
47
+ </div>
48
+ <div id="social">
49
+ <svg class="icon" role="presentation" aria-hidden="true">
50
+ <use href="/icons.svg#social-icon"></use>
51
+ </svg>
52
+ <h2>Connect with us</h2>
53
+ <p>Join the Vite community</p>
54
+ <ul>
55
+ <li>
56
+ <a href="https://github.com/vitejs/vite" target="_blank">
57
+ <svg class="button-icon" role="presentation" aria-hidden="true">
58
+ <use href="/icons.svg#github-icon"></use>
59
+ </svg>
60
+ GitHub
61
+ </a>
62
+ </li>
63
+ <li>
64
+ <a href="https://chat.vite.dev/" target="_blank">
65
+ <svg class="button-icon" role="presentation" aria-hidden="true">
66
+ <use href="/icons.svg#discord-icon"></use>
67
+ </svg>
68
+ Discord
69
+ </a>
70
+ </li>
71
+ <li>
72
+ <a href="https://x.com/vite_js" target="_blank">
73
+ <svg class="button-icon" role="presentation" aria-hidden="true">
74
+ <use href="/icons.svg#x-icon"></use>
75
+ </svg>
76
+ X.com
77
+ </a>
78
+ </li>
79
+ <li>
80
+ <a href="https://bsky.app/profile/vite.dev" target="_blank">
81
+ <svg class="button-icon" role="presentation" aria-hidden="true">
82
+ <use href="/icons.svg#bluesky-icon"></use>
83
+ </svg>
84
+ Bluesky
85
+ </a>
86
+ </li>
87
+ </ul>
88
+ </div>
89
+ </section>
90
+
91
+ <div class="ticks"></div>
92
+ <section id="spacer"></section>
93
+ </template>
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <section class="relative min-h-[calc(100vh-120px)] md:min-h-screen flex items-center px-4 md:px-10 pt-8 md:pt-20 overflow-hidden">
3
+ <div
4
+ class="hidden md:flex w-1/3 h-full justify-end items-end relative"
5
+ :style="{ transform: `translateY(${scrollY * 0.5}px)`, opacity: 1 - scrollY / 600 }"
6
+ >
7
+ <img
8
+ :src="config.author.aiPortrait"
9
+ class="max-h-[80vh] object-contain drop-shadow-2xl"
10
+ style="-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); mask-image: linear-gradient(to bottom, black 70%, transparent 100%);"
11
+ alt="AI Portrait"
12
+ />
13
+ </div>
14
+
15
+ <div class="w-full md:w-2/3 md:pl-16 z-10">
16
+ <div class="md:hidden flex justify-center mb-8">
17
+ <img
18
+ :src="config.author.aiPortrait"
19
+ class="w-full max-w-[260px] object-contain drop-shadow-xl"
20
+ style="-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 100%); mask-image: linear-gradient(to bottom, black 75%, transparent 100%);"
21
+ alt="AI Portrait"
22
+ />
23
+ </div>
24
+
25
+ <h1 class="text-3xl sm:text-4xl md:text-5xl font-extrabold mb-5 md:mb-6 dark:text-white">Hi, I'm {{ config.author.name }}</h1>
26
+ <p class="text-base sm:text-lg md:text-xl text-gray-700 dark:text-gray-300 mb-9 md:mb-12 max-w-2xl leading-relaxed">
27
+ {{ config.author.intro }}
28
+ </p>
29
+
30
+ <a
31
+ :href="config.blog.url"
32
+ target="_blank"
33
+ rel="noopener noreferrer"
34
+ class="inline-block px-6 md:px-8 py-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 hover:shadow-lg hover:-translate-y-1 transition-all duration-300"
35
+ >
36
+ 访问我的博客
37
+ </a>
38
+ </div>
39
+ </section>
40
+ </template>
41
+
42
+ <script setup>
43
+ import { ref, onMounted, onUnmounted } from 'vue'
44
+ import config from '../../main.config.js'
45
+
46
+ const scrollY = ref(0)
47
+ const handleScroll = () => {
48
+ scrollY.value = window.scrollY
49
+ }
50
+
51
+ onMounted(() => window.addEventListener('scroll', handleScroll))
52
+ onUnmounted(() => window.removeEventListener('scroll', handleScroll))
53
+ </script>
@@ -0,0 +1,160 @@
1
+ <template>
2
+ <section class="py-14 md:py-20" id="projects">
3
+ <div class="flex items-end justify-between mb-6 md:mb-10 gap-4">
4
+ <h2 class="text-2xl md:text-3xl font-bold dark:text-white">我的项目</h2>
5
+ <a
6
+ v-if="isApiMode && config.github.username"
7
+ :href="`https://github.com/${config.github.username}?tab=repositories`"
8
+ target="_blank"
9
+ rel="noopener noreferrer"
10
+ class="text-xs md:text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline shrink-0"
11
+ >
12
+ 查看全部仓库
13
+ </a>
14
+ </div>
15
+
16
+ <div v-if="loading" class="text-sm text-gray-500 dark:text-gray-400">
17
+ {{ isApiMode ? '正在读取 GitHub 仓库...' : '正在读取手动项目配置...' }}
18
+ </div>
19
+ <div v-else-if="error" class="text-sm text-red-500">加载失败:{{ error }}</div>
20
+
21
+ <div v-else class="columns-1 md:columns-2 gap-6 space-y-6">
22
+ <a
23
+ v-for="repo in repos"
24
+ :key="repo.id || repo.name"
25
+ :href="repo.url"
26
+ target="_blank"
27
+ rel="noopener noreferrer"
28
+ class="block break-inside-avoid rounded-[1.5rem] md:rounded-[2rem] overflow-hidden bg-white/60 dark:bg-gray-800/60 border border-white/40 dark:border-gray-700/40 backdrop-blur-xl shadow-[0_12px_32px_rgba(15,23,42,0.10)] dark:shadow-[0_16px_36px_rgba(0,0,0,0.45)] transition-transform duration-300 hover:-translate-y-1"
29
+ >
30
+ <img
31
+ v-if="repo.cover"
32
+ :src="repo.cover"
33
+ :alt="repo.name + ' cover'"
34
+ class="w-full h-40 md:h-44 object-cover"
35
+ />
36
+
37
+ <div class="p-5 md:p-6">
38
+ <div class="flex items-start justify-between gap-4 mb-3">
39
+ <h3 class="text-lg md:text-xl font-bold dark:text-white line-clamp-2">{{ repo.name }}</h3>
40
+ <span class="text-xs px-2 py-1 rounded-full bg-black/5 dark:bg-white/10 text-gray-600 dark:text-gray-300 whitespace-nowrap">
41
+ {{ repo.visibility || 'public' }}
42
+ </span>
43
+ </div>
44
+
45
+ <p class="text-sm text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">{{ repo.description || '暂无描述' }}</p>
46
+
47
+ <div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
48
+ <span>{{ repo.language || 'Unknown' }}</span>
49
+ <span v-if="isApiMode">★ {{ repo.stars || 0 }}</span>
50
+ </div>
51
+ </div>
52
+ </a>
53
+ </div>
54
+ </section>
55
+ </template>
56
+
57
+ <script setup>
58
+ import { computed, onMounted, ref } from 'vue'
59
+ import config from '../../main.config.js'
60
+
61
+ const repos = ref([])
62
+ const loading = ref(true)
63
+ const error = ref('')
64
+
65
+ const isApiMode = computed(() => {
66
+ const value = config.github?.useGithubApi
67
+ return value === true || value === 'true'
68
+ })
69
+
70
+ const normalizeApiRepo = (repo) => ({
71
+ id: repo.id,
72
+ name: repo.name,
73
+ url: repo.html_url,
74
+ description: repo.description,
75
+ language: repo.language,
76
+ visibility: repo.visibility,
77
+ stars: repo.stargazers_count,
78
+ cover: config.github?.covers?.[repo.name] || ''
79
+ })
80
+
81
+ const normalizeManualRepo = (repo, idx) => ({
82
+ id: repo.id || `manual-${idx}`,
83
+ name: repo.name || `Project ${idx + 1}`,
84
+ url: repo.url || '#',
85
+ description: repo.description || '',
86
+ language: repo.language || 'Unknown',
87
+ visibility: repo.visibility || 'public',
88
+ stars: 0,
89
+ cover: repo.cover || ''
90
+ })
91
+
92
+ const loadManualRepos = () => {
93
+ const manualRepos = Array.isArray(config.github?.manualRepos) ? config.github.manualRepos : []
94
+ repos.value = manualRepos.map(normalizeManualRepo)
95
+ loading.value = false
96
+ }
97
+
98
+ const loadGithubRepos = async () => {
99
+ const username = config.github?.username
100
+ const maxRepos = config.github?.maxRepos || 10
101
+ const excludeForks = config.github?.excludeForks !== false
102
+
103
+ if (!username) {
104
+ throw new Error('github.username 未配置')
105
+ }
106
+
107
+ const cacheKey = `github_repos_${username}`
108
+ const cacheExpiryKey = `github_repos_expiry_${username}`
109
+ const now = Date.now()
110
+
111
+ const cachedExpiry = localStorage.getItem(cacheExpiryKey)
112
+ if (cachedExpiry && Number.parseInt(cachedExpiry, 10) > now) {
113
+ try {
114
+ const cachedRepos = JSON.parse(localStorage.getItem(cacheKey) || 'null')
115
+ if (Array.isArray(cachedRepos)) {
116
+ repos.value = cachedRepos.map(normalizeApiRepo)
117
+ loading.value = false
118
+ return
119
+ }
120
+ } catch (e) {
121
+ console.error('读取缓存失败:', e)
122
+ }
123
+ }
124
+
125
+ const token = import.meta.env.VITE_GITHUB_TOKEN
126
+ const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=${maxRepos}`
127
+ const headers = {
128
+ Accept: 'application/vnd.github+json',
129
+ ...(token && { Authorization: `Bearer ${token}` })
130
+ }
131
+
132
+ const res = await fetch(url, { headers })
133
+ if (!res.ok) {
134
+ throw new Error(`GitHub API ${res.status}`)
135
+ }
136
+
137
+ const data = await res.json()
138
+ const filteredRepos = excludeForks ? data.filter((repo) => !repo.fork) : data
139
+
140
+ localStorage.setItem(cacheKey, JSON.stringify(filteredRepos))
141
+ localStorage.setItem(cacheExpiryKey, (now + 24 * 60 * 60 * 1000).toString())
142
+
143
+ repos.value = filteredRepos.map(normalizeApiRepo)
144
+ loading.value = false
145
+ }
146
+
147
+ onMounted(async () => {
148
+ try {
149
+ if (!isApiMode.value) {
150
+ loadManualRepos()
151
+ return
152
+ }
153
+
154
+ await loadGithubRepos()
155
+ } catch (e) {
156
+ error.value = e instanceof Error ? e.message : '未知错误'
157
+ loading.value = false
158
+ }
159
+ })
160
+ </script>
@@ -0,0 +1,109 @@
1
+ <template>
2
+ <nav
3
+ ref="navRef"
4
+ class="fixed top-0 left-0 right-0 z-50 h-[116px] md:h-[76px] px-4 md:px-8 py-3 md:py-4 backdrop-blur-md bg-white/70 dark:bg-[#424242]/80 transition-colors duration-500"
5
+ >
6
+ <div class="relative h-full">
7
+ <div class="flex items-center justify-between md:h-full">
8
+ <button
9
+ @click="goHome"
10
+ class="flex items-center gap-2.5 md:gap-3 h-full cursor-pointer"
11
+ aria-label="Go Home"
12
+ >
13
+ <img :src="config.author.avatar" class="w-9 h-9 md:w-10 md:h-10 rounded-full shadow-md" alt="avatar" />
14
+ <span class="name-chip font-bold text-base md:text-lg dark:text-white">{{ config.author.name }}</span>
15
+ </button>
16
+
17
+ <div class="hidden md:flex gap-2 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
18
+ <a
19
+ v-for="link in config.navLinks"
20
+ :key="link.name"
21
+ :href="link.url"
22
+ @click.prevent="handleNavClick(link.url)"
23
+ class="px-4 py-2 rounded-xl transition-all duration-300 hover:bg-black/10 dark:hover:bg-white/20 dark:text-gray-200"
24
+ >
25
+ {{ link.name }}
26
+ </a>
27
+ </div>
28
+
29
+ <div class="z-[60]">
30
+ <ThemeToggle />
31
+ </div>
32
+ </div>
33
+
34
+ <div class="md:hidden mt-2 overflow-x-auto no-scrollbar">
35
+ <div class="flex gap-2 pr-2">
36
+ <a
37
+ v-for="link in config.navLinks"
38
+ :key="`mobile-${link.name}`"
39
+ :href="link.url"
40
+ @click.prevent="handleNavClick(link.url)"
41
+ class="shrink-0 px-3 py-1.5 text-sm rounded-lg bg-white/60 dark:bg-white/10 dark:text-gray-200"
42
+ >
43
+ {{ link.name }}
44
+ </a>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </nav>
49
+ </template>
50
+
51
+ <script setup>
52
+ import { ref } from 'vue'
53
+ import { useRoute, useRouter } from 'vue-router'
54
+ import config from '../../main.config.js'
55
+ import ThemeToggle from './ThemeToggle.vue'
56
+
57
+ const navRef = ref(null)
58
+ const router = useRouter()
59
+ const route = useRoute()
60
+
61
+ const goHome = () => {
62
+ router.push('/')
63
+ }
64
+
65
+ const scrollToHashWithRetry = (hash, retries = 20) => {
66
+ const target = document.querySelector(hash)
67
+ if (!target) {
68
+ if (retries <= 0) return
69
+ window.requestAnimationFrame(() => scrollToHashWithRetry(hash, retries - 1))
70
+ return
71
+ }
72
+
73
+ const navHeight = navRef.value?.offsetHeight || 0
74
+ const targetTop = target.getBoundingClientRect().top + window.scrollY - navHeight - 8
75
+ window.scrollTo({ top: targetTop, behavior: 'smooth' })
76
+ }
77
+
78
+ const smoothScrollToHash = (hash) => {
79
+ const target = document.querySelector(hash)
80
+ if (!target) return
81
+ const navHeight = navRef.value?.offsetHeight || 0
82
+ const targetTop = target.getBoundingClientRect().top + window.scrollY - navHeight - 8
83
+ window.scrollTo({ top: targetTop, behavior: 'smooth' })
84
+ }
85
+
86
+ const handleNavClick = (url) => {
87
+ if (!url) return
88
+
89
+ if (url.startsWith('#')) {
90
+ if (route.path !== '/') {
91
+ router.push({ path: '/', hash: url }).then(() => {
92
+ scrollToHashWithRetry(url)
93
+ })
94
+ return
95
+ }
96
+
97
+ smoothScrollToHash(url)
98
+ router.replace({ hash: url })
99
+ return
100
+ }
101
+
102
+ if (url.startsWith('/')) {
103
+ router.push(url)
104
+ return
105
+ }
106
+
107
+ window.location.href = url
108
+ }
109
+ </script>