[READ-ONLY] a fast, modern browser for the npm registry
at main 304 lines 10 kB view raw
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>