-
-
Notifications
You must be signed in to change notification settings - Fork 244
feat: add /holiday page
#1363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add /holiday page
#1363
Changes from all commits
99001ca
ea9e429
d12ea73
924c6f2
abb7b98
c7e97dc
70d1f34
1f4ae31
4124562
7f3c9a7
f1fe47d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| <script setup lang="ts"> | ||
| definePageMeta({ | ||
| name: 'vacations', | ||
| }) | ||
| useSeoMeta({ | ||
| title: () => `${$t('vacations.title')} - npmx`, | ||
| description: () => $t('vacations.meta_description'), | ||
| ogTitle: () => `${$t('vacations.title')} - npmx`, | ||
| ogDescription: () => $t('vacations.meta_description'), | ||
| twitterTitle: () => `${$t('vacations.title')} - npmx`, | ||
| twitterDescription: () => $t('vacations.meta_description'), | ||
| }) | ||
| defineOgImageComponent('Default', { | ||
| title: () => $t('vacations.title'), | ||
| description: () => $t('vacations.meta_description'), | ||
| }) | ||
| const router = useRouter() | ||
| const canGoBack = useCanGoBack() | ||
| // --- Cosy fireplace easter egg --- | ||
| const logClicks = ref(0) | ||
| const fireVisible = ref(false) | ||
| function pokeLog() { | ||
| logClicks.value++ | ||
| if (logClicks.value >= 3) { | ||
| fireVisible.value = true | ||
| } | ||
| } | ||
| // Icons that tile across the banner, repeating to fill. | ||
| // Classes must be written out statically so UnoCSS can detect them at build time. | ||
| const icons = [ | ||
| 'i-carbon:snowflake', | ||
| 'i-carbon:mountain', | ||
| 'i-carbon:tree', | ||
| 'i-carbon:cafe', | ||
| 'i-carbon:book', | ||
| 'i-carbon:music', | ||
| 'i-carbon:snowflake', | ||
| 'i-carbon:star', | ||
| 'i-carbon:moon', | ||
| ] as const | ||
| // --- .ics calendar reminder --- | ||
| // Format as UTC for the .ics file | ||
| const fmt = (d: Date) => | ||
| d | ||
| .toISOString() | ||
| .replace(/[-:]/g, '') | ||
| .replace(/\.\d{3}/, '') | ||
| // Pick a random daytime hour (9–17) in the user's local timezone on Feb 22 | ||
| // so reminders are staggered and people don't all flood in at once. | ||
| function downloadIcs() { | ||
| const hour = 9 + Math.floor(Math.random() * 9) // 9..17 | ||
| const start = new Date(2026, 1, 22, hour, 0, 0) // month is 0-indexed | ||
| const end = new Date(2026, 1, 22, hour + 1, 0, 0) | ||
| const uid = `npmx-vacations-${start.getTime()}@npmx.dev` | ||
| const ics = [ | ||
| 'BEGIN:VCALENDAR', | ||
| 'VERSION:2.0', | ||
| 'PRODID:-//npmx//holiday//EN', | ||
| 'BEGIN:VEVENT', | ||
| `DTSTART:${fmt(start)}`, | ||
| `DTEND:${fmt(end)}`, | ||
| `SUMMARY:npmx Discord is back!`, | ||
| `DESCRIPTION:The npmx team is back from vacation. Time to rejoin! https://chat.npmx.dev`, | ||
| 'STATUS:CONFIRMED', | ||
| `UID:${uid}`, | ||
| 'END:VEVENT', | ||
| 'END:VCALENDAR', | ||
| ].join('\r\n') | ||
| const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }) | ||
| const url = URL.createObjectURL(blob) | ||
| const a = document.createElement('a') | ||
| a.href = url | ||
| a.download = 'npmx-discord-reminder.ics' | ||
| a.click() | ||
| URL.revokeObjectURL(url) | ||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
| <main class="container flex-1 py-12 sm:py-16 overflow-x-hidden max-w-full"> | ||
| <article class="max-w-2xl mx-auto"> | ||
| <header class="mb-12"> | ||
| <div class="max-w-2xl mx-auto py-8 bg-none flex justify-center"> | ||
| <!-- Icon / Illustration --> | ||
| <div class="relative inline-block"> | ||
| <div class="absolute inset-0 bg-accent/20 blur-3xl rounded-full" aria-hidden="true" /> | ||
| <span class="relative text-8xl sm:text-9xl animate-bounce-slow inline-block">🏖️</span> | ||
| </div> | ||
| </div> | ||
| <div class="flex items-baseline justify-between gap-4 mb-4"> | ||
| <h1 class="font-mono text-3xl sm:text-4xl font-medium"> | ||
| {{ $t('vacations.heading') }} | ||
| </h1> | ||
| <button | ||
| type="button" | ||
| 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" | ||
| @click="router.back()" | ||
| v-if="canGoBack" | ||
| > | ||
| <span class="i-carbon:arrow-left rtl-flip w-4 h-4" aria-hidden="true" /> | ||
| <span class="sr-only sm:not-sr-only">{{ $t('nav.back') }}</span> | ||
| </button> | ||
|
Comment on lines
+105
to
+113
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove per-button These buttons should rely on the global focus-visible styling rather than inline utilities. 🧹 Suggested diff- 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"
+ class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
...
- 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"
+ class="relative shrink-0 cursor-pointer rounded transition-transform duration-200 hover:scale-110 w-5 h-5 sm:w-6 sm:h-6"Based on learnings: In the npmx.dev project, focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components. Also applies to: 176-179 |
||
| </div> | ||
| <i18n-t | ||
| keypath="vacations.subtitle" | ||
| tag="p" | ||
| scope="global" | ||
| class="text-fg-muted text-lg sm:text-xl" | ||
| > | ||
| <template #some> | ||
| <span class="line-through decoration-fg">{{ | ||
| $t('vacations.stats.subtitle.some') | ||
| }}</span> | ||
| {{ ' ' }} | ||
| <strong class="text-fg">{{ $t('vacations.stats.subtitle.all') }}</strong> | ||
| </template> | ||
| </i18n-t> | ||
| </header> | ||
|
|
||
| <div | ||
| class="grid grid-cols-3 justify-center gap-4 sm:gap-8 mb-8 py-8 border-y border-border/50" | ||
| > | ||
| <div class="space-y-1 text-center"> | ||
| <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> | ||
| {{ $t('vacations.stats.contributors_text') }} | ||
| </div> | ||
| <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> | ||
| {{ $t('vacations.stats.contributors') }} | ||
| </div> | ||
| </div> | ||
| <div class="space-y-1 text-center"> | ||
| <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> | ||
| {{ $t('vacations.stats.commits_text') }} | ||
| </div> | ||
| <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> | ||
| {{ $t('vacations.stats.commits') }} | ||
| </div> | ||
| </div> | ||
| <div class="space-y-1 text-center"> | ||
| <div class="font-mono text-2xl sm:text-3xl font-bold text-fg"> | ||
| {{ $t('vacations.stats.pr_text') }} | ||
| </div> | ||
| <div class="text-xs sm:text-sm text-fg-subtle uppercase tracking-wider"> | ||
| {{ $t('vacations.stats.pr') }} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <section class="prose prose-invert max-w-none space-y-8"> | ||
| <!-- What's happening --> | ||
| <div> | ||
| <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> | ||
| {{ $t('vacations.what.title') }} | ||
| </h2> | ||
| <p class="text-fg-muted leading-relaxed mb-4"> | ||
| <i18n-t keypath="vacations.what.p1" tag="span" scope="global"> | ||
| <template #dates> | ||
| <strong class="text-fg">{{ $t('vacations.what.dates') }}</strong> | ||
| </template> | ||
| </i18n-t> | ||
| </p> | ||
| <p class="text-fg-muted leading-relaxed mb-4"> | ||
| <i18n-t keypath="vacations.what.p2" tag="span" scope="global"> | ||
| <template #garden> | ||
| <code class="font-mono text-fg text-sm">{{ $t('vacations.what.garden') }}</code> | ||
| </template> | ||
| </i18n-t> | ||
| </p> | ||
| </div> | ||
|
|
||
| <!-- In the meantime --> | ||
| <div> | ||
| <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> | ||
| {{ $t('vacations.meantime.title') }} | ||
| </h2> | ||
| <p class="text-fg-muted leading-relaxed"> | ||
| <i18n-t keypath="vacations.meantime.p1" tag="span" scope="global"> | ||
| <template #repo> | ||
| <LinkBase to="https://repo.npmx.dev"> | ||
| {{ $t('vacations.meantime.repo_link') }} | ||
| </LinkBase> | ||
| </template> | ||
| </i18n-t> | ||
| </p> | ||
| </div> | ||
|
|
||
| <!-- Icon banner — a single row of cosy icons, clipped to fill width --> | ||
| <div | ||
| class="relative mb-12 px-4 border border-border rounded-lg bg-bg-subtle overflow-hidden select-none" | ||
| :aria-label="$t('vacations.illustration_alt')" | ||
| role="group" | ||
| > | ||
| <div class="flex items-center gap-4 sm:gap-5 py-3 sm:py-4 w-max"> | ||
| <template v-for="n in 4" :key="`set-${n}`"> | ||
| <!-- Campsite icon — click it 3x to light the fire --> | ||
| <button | ||
| type="button" | ||
| 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" | ||
| :aria-label="$t('vacations.poke_log')" | ||
| @click="pokeLog" | ||
| > | ||
| <span | ||
| class="absolute inset-0 i-carbon:fire w-5 h-5 sm:w-6 sm:h-6 text-orange-400 transition-opacity duration-400" | ||
| :class="fireVisible ? 'opacity-100' : 'opacity-0'" | ||
| /> | ||
| <span | ||
| class="absolute inset-0 i-carbon:campsite w-5 h-5 sm:w-6 sm:h-6 transition-colors duration-400" | ||
| :class="fireVisible ? 'text-amber-700' : ''" | ||
| /> | ||
| </button> | ||
| <span | ||
| v-for="(icon, i) in icons" | ||
| :key="`${n}-${i}`" | ||
| class="shrink-0 w-5 h-5 sm:w-6 sm:h-6 opacity-40" | ||
| :class="icon" | ||
| aria-hidden="true" | ||
| /> | ||
| </template> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- See you soon --> | ||
| <div> | ||
| <h2 class="text-lg text-fg-subtle uppercase tracking-wider mb-4"> | ||
| {{ $t('vacations.return.title') }} | ||
| </h2> | ||
| <p class="text-fg-muted leading-relaxed mb-6"> | ||
| <i18n-t keypath="vacations.return.p1" tag="span" scope="global"> | ||
| <template #social> | ||
| <LinkBase to="https://social.npmx.dev"> | ||
| {{ $t('vacations.return.social_link') }} | ||
| </LinkBase> | ||
| </template> | ||
| </i18n-t> | ||
| </p> | ||
|
|
||
| <!-- Add to calendar button --> | ||
| <ButtonBase classicon="i-carbon:calendar" @click="downloadIcs"> | ||
| {{ $t('vacations.return.add_to_calendar') }} | ||
| </ButtonBase> | ||
| </div> | ||
| </section> | ||
| </article> | ||
| </main> | ||
| </template> | ||
|
|
||
| <style scoped> | ||
| .animate-bounce-slow { | ||
| animation: bounce 3s infinite; | ||
| } | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .animate-bounce-slow { | ||
| animation: none; | ||
| } | ||
| } | ||
| @keyframes bounce { | ||
| 0%, | ||
| 100% { | ||
| transform: translateY(-5%); | ||
| animation-timing-function: cubic-bezier(0.8, 0, 1, 1); | ||
| } | ||
| 50% { | ||
| transform: translateY(0); | ||
| animation-timing-function: cubic-bezier(0, 0, 0.2, 1); | ||
| } | ||
| } | ||
| </style> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding
DTSTAMPfor ICS spec compliance.Per RFC 5545,
DTSTAMPis a required property forVEVENT. While most calendar apps are lenient, some strict parsers may reject the file without it.🛠️ Suggested fix
const ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//npmx//vacations//EN', 'BEGIN:VEVENT', + `DTSTAMP:${fmt(new Date())}`, `DTSTART:${fmt(start)}`, `DTEND:${fmt(end)}`, `SUMMARY:npmx Discord is back!`,📝 Committable suggestion