forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2definePageMeta({
3 name: 'vacations',
4})
5
6useSeoMeta({
7 title: () => `${$t('vacations.title')} - npmx`,
8 description: () => $t('vacations.meta_description'),
9 ogTitle: () => `${$t('vacations.title')} - npmx`,
10 ogDescription: () => $t('vacations.meta_description'),
11 twitterTitle: () => `${$t('vacations.title')} - npmx`,
12 twitterDescription: () => $t('vacations.meta_description'),
13})
14
15defineOgImageComponent('Default', {
16 title: () => $t('vacations.title'),
17 description: () => $t('vacations.meta_description'),
18})
19
20const router = useRouter()
21const canGoBack = useCanGoBack()
22
23const { data: stats } = useFetch('/api/repo-stats')
24
25/**
26 * Formats a number into a compact human-readable string.
27 * e.g. 1142 → "1.1k+", 163 → "160+"
28 */
29function formatStat(n: number): string {
30 if (n >= 1000) {
31 const k = Math.floor(n / 100) / 10
32 return `${k}k+`
33 }
34 return `${Math.floor(n / 10) * 10}+`
35}
36
37// --- Cosy fireplace easter egg ---
38const logClicks = ref(0)
39const fireVisible = ref(false)
40function pokeLog() {
41 logClicks.value++
42 if (logClicks.value >= 3) {
43 fireVisible.value = true
44 }
45}
46
47// Icons that tile across the banner, repeating to fill.
48// Classes must be written out statically so UnoCSS can detect them at build time.
49const icons = [
50 'i-lucide:snowflake',
51 'i-lucide:mountain',
52 'i-lucide:tree-pine',
53 'i-lucide:coffee',
54 'i-lucide:book',
55 'i-lucide:music',
56 'i-lucide:snowflake',
57 'i-lucide:star',
58 'i-lucide:moon',
59] as const
60
61// --- .ics calendar reminder ---
62
63// Format as UTC for the .ics file
64const fmt = (d: Date) =>
65 d
66 .toISOString()
67 .replace(/[-:]/g, '')
68 .replace(/\.\d{3}/, '')
69
70// Pick a random daytime hour (9–17) in the user's local timezone on Feb 22
71// so reminders are staggered and people don't all flood in at once.
72function downloadIcs() {
73 const hour = 9 + Math.floor(Math.random() * 9) // 9..17
74 const start = new Date(2026, 1, 22, hour, 0, 0) // month is 0-indexed
75 const end = new Date(2026, 1, 22, hour + 1, 0, 0)
76
77 const uid = `npmx-vacations-${start.getTime()}@npmx.dev`
78
79 const ics = [
80 'BEGIN:VCALENDAR',
81 'VERSION:2.0',
82 'PRODID:-//npmx//recharging//EN',
83 'BEGIN:VEVENT',
84 `DTSTART:${fmt(start)}`,
85 `DTEND:${fmt(end)}`,
86 `SUMMARY:npmx Discord is back!`,
87 `DESCRIPTION:The npmx team is back from vacation. Time to rejoin! https://chat.npmx.dev`,
88 'STATUS:CONFIRMED',
89 `UID:${uid}`,
90 'END:VEVENT',
91 'END:VCALENDAR',
92 ].join('\r\n')
93
94 const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' })
95 const url = URL.createObjectURL(blob)
96 const a = document.createElement('a')
97 a.href = url
98 a.download = 'npmx-discord-reminder.ics'
99 a.click()
100 URL.revokeObjectURL(url)
101}
102</script>
103
104<template>
105 <main class="container flex-1 py-12 sm:py-16 overflow-x-hidden max-w-full">
106 <article class="max-w-2xl mx-auto">
107 <header class="mb-12">
108 <div class="max-w-2xl mx-auto py-8 bg-none flex justify-center">
109 <!-- Icon / Illustration -->
110 <div class="relative inline-block">
111 <div class="absolute inset-0 bg-accent/20 blur-3xl rounded-full" aria-hidden="true" />
112 <span class="relative text-8xl sm:text-9xl animate-bounce-slow inline-block">🏖️</span>
113 </div>
114 </div>
115 <div class="flex items-baseline justify-between gap-4 mb-4">
116 <h1 class="font-mono text-3xl sm:text-4xl font-medium">
117 {{ $t('vacations.heading') }}
118 </h1>
119 <button
120 type="button"
121 class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
122 @click="router.back()"
123 v-if="canGoBack"
124 >
125 <span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
126 <span class="sr-only sm:not-sr-only">{{ $t('nav.back') }}</span>
127 </button>
128 </div>
129 <i18n-t
130 keypath="vacations.subtitle"
131 tag="p"
132 scope="global"
133 class="text-fg-muted text-lg sm:text-xl"
134 >
135 <template #some>
136 <span class="line-through decoration-fg">{{
137 $t('vacations.stats.subtitle.some')
138 }}</span>
139 {{ ' ' }}
140 <strong class="text-fg">{{ $t('vacations.stats.subtitle.all') }}</strong>
141 </template>
142 </i18n-t>
143 </header>
144 <!-- Bluesky post embed -->
145 <div class="my-8">
146 <BlueskyPostEmbed
147 uri="at://did:plc:u5zp7npt5kpueado77kuihyz/app.bsky.feed.post/3mejzn5mrcc2g"
148 />
149 </div>
150
151 <section class="prose prose-invert max-w-none space-y-8">
152 <!-- What's happening -->
153 <div>
154 <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4">
155 {{ $t('vacations.what.title') }}
156 </h2>
157 <p class="text-fg-muted leading-relaxed mb-4">
158 <i18n-t keypath="vacations.what.p1" tag="span" scope="global">
159 <template #dates>
160 <strong class="text-fg">{{ $t('vacations.what.dates') }}</strong>
161 </template>
162 </i18n-t>
163 </p>
164 <p class="text-fg-muted leading-relaxed mb-4">
165 <i18n-t keypath="vacations.what.p2" tag="span" scope="global">
166 <template #garden>
167 <code class="font-mono text-fg text-sm">{{ $t('vacations.what.garden') }}</code>
168 </template>
169 </i18n-t>
170 </p>
171 </div>
172
173 <!-- In the meantime -->
174 <div>
175 <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4">
176 {{ $t('vacations.meantime.title') }}
177 </h2>
178 <p class="text-fg-muted leading-relaxed">
179 <i18n-t keypath="vacations.meantime.p1" tag="span" scope="global">
180 <template #site>
181 <LinkBase class="font-sans" to="/">npmx.dev</LinkBase>
182 </template>
183 <template #repo>
184 <LinkBase class="font-sans" to="https://repo.npmx.dev">
185 {{ $t('vacations.meantime.repo_link') }}
186 </LinkBase>
187 </template>
188 </i18n-t>
189 </p>
190 </div>
191
192 <!-- Icon banner — a single row of cosy icons, clipped to fill width -->
193 <div
194 class="relative mb-12 px-4 border border-border rounded-lg bg-bg-subtle overflow-hidden select-none"
195 :aria-label="$t('vacations.illustration_alt')"
196 role="group"
197 >
198 <div class="flex items-center gap-4 sm:gap-5 py-3 sm:py-4 w-max">
199 <template v-for="n in 4" :key="`set-${n}`">
200 <!-- Campsite icon — click it 3x to light the fire -->
201 <button
202 type="button"
203 class="relative shrink-0 cursor-pointer rounded transition-transform duration-200 hover:scale-110 focus-visible:outline-accent/70 w-5 h-5 sm:w-6 sm:h-6"
204 :aria-label="$t('vacations.poke_log')"
205 @click="pokeLog"
206 >
207 <span
208 class="absolute inset-0 i-lucide:flame-kindling w-5 h-5 sm:w-6 sm:h-6 text-orange-400 transition-opacity duration-400"
209 :class="fireVisible ? 'opacity-100' : 'opacity-0'"
210 />
211 <span
212 class="absolute inset-0 i-lucide:tent w-5 h-5 sm:w-6 sm:h-6 transition-colors duration-400"
213 :class="fireVisible ? 'text-amber-700' : ''"
214 />
215 </button>
216 <span
217 v-for="(icon, i) in icons"
218 :key="`${n}-${i}`"
219 class="shrink-0 w-5 h-5 sm:w-6 sm:h-6 opacity-40"
220 :class="icon"
221 aria-hidden="true"
222 />
223 </template>
224 </div>
225 </div>
226
227 <!-- See you soon -->
228 <div>
229 <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4">
230 {{ $t('vacations.return.title') }}
231 </h2>
232 <p class="text-fg-muted leading-relaxed mb-6">
233 <i18n-t keypath="vacations.return.p1" tag="span" scope="global">
234 <template #social>
235 <LinkBase class="font-sans" to="https://social.npmx.dev">
236 {{ $t('vacations.return.social_link') }}
237 </LinkBase>
238 </template>
239 </i18n-t>
240 </p>
241
242 <!-- Add to calendar button -->
243 <ButtonBase classicon="i-lucide:calendar" @click="downloadIcs">
244 {{ $t('vacations.return.add_to_calendar') }}
245 </ButtonBase>
246 </div>
247
248 <div
249 v-if="stats"
250 class="grid grid-cols-3 justify-center gap-4 sm:gap-8 mb-8 py-8 border-y border-border/50"
251 >
252 <div class="space-y-1 text-center">
253 <div class="font-mono text-2xl sm:text-3xl font-bold text-fg">
254 {{ formatStat(stats.contributors) }}
255 </div>
256 <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider">
257 {{ $t('vacations.stats.contributors') }}
258 </div>
259 </div>
260 <div class="space-y-1 text-center">
261 <div class="font-mono text-2xl sm:text-3xl font-bold text-fg">
262 {{ formatStat(stats.commits) }}
263 </div>
264 <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider">
265 {{ $t('vacations.stats.commits') }}
266 </div>
267 </div>
268 <div class="space-y-1 text-center">
269 <div class="font-mono text-2xl sm:text-3xl font-bold text-fg">
270 {{ formatStat(stats.pullRequests) }}
271 </div>
272 <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider">
273 {{ $t('vacations.stats.pr') }}
274 </div>
275 </div>
276 </div>
277 </section>
278 </article>
279 </main>
280</template>
281
282<style scoped>
283.animate-bounce-slow {
284 animation: bounce 3s infinite;
285}
286
287@media (prefers-reduced-motion: reduce) {
288 .animate-bounce-slow {
289 animation: none;
290 }
291}
292
293@keyframes bounce {
294 0%,
295 100% {
296 transform: translateY(-5%);
297 animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
298 }
299 50% {
300 transform: translateY(0);
301 animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
302 }
303}
304</style>