feat(docs): Merry Christmas 2025 🎄

This commit is contained in:
Neko Ayaka
2025-12-24 23:11:14 +08:00
parent 32d9d1405c
commit bd9358e44c
6 changed files with 327 additions and 6 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+37 -6
View File
@@ -6,15 +6,21 @@ import { createAnimatable } from 'animejs'
import { onMounted, shallowRef, useTemplateRef, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import homeBackgroundChristmas20251224 from '../assets/home-cover-2025-12-24-bg.avif'
// NOTICE: for unknown reasons, without `?no-inline`, the inlined SVG use in the SFC template
// of `url(...)` doesn't work properly in Vite/Vue, the entire SVG content either got incorrectly
// cached or erased in dev/prod, causing the mask pattern to not show up.
import homeBackgroundPatternGhost from '../assets/home-patterns-ghost.svg?no-inline'
import homeBackgroundPatternLollipop from '../assets/home-patterns-lollipop.svg?no-inline'
import ParallaxCover from './ParallaxCover.vue'
import ParallaxCoverChristmas20251224 from './ParallaxCoverChristmas20251224.vue'
import ParallaxCoverHalloween20251029 from './ParallaxCoverHalloween20251029.vue'
import Snowfall from './Snowfall.vue'
import { isBetweenHalloweenAndHalfOfNovember } from '../composables/date'
import {
isBetweenChristmasAndHalfOfJanuary,
isBetweenHalloweenAndHalfOfNovember,
} from '../composables/date'
const heroRef = useTemplateRef<HTMLDivElement>('hero')
@@ -106,6 +112,19 @@ watchEffect((onCleanup) => {
>
<div i-twemoji:jack-o-lantern />Happy Halloween!<div i-twemoji:jack-o-lantern />
</div>
<div
v-if="true || isBetweenChristmasAndHalfOfJanuary(new Date())"
:class="[
'w-fit',
'flex items-center gap-2',
'px-4 py-1',
'mb-3 sm:mb-4 lg:mb-8',
'border-3 rounded-full border-white/75 bg-red-300/75 text-neutral-800 dark:border-white/50 dark:bg-red-500/50 dark:text-white',
'text-sm sm:text-base align-middle',
]"
>
<div i-twemoji:christmas-tree />Merry Christmas!<div i-twemoji:christmas-tree />
</div>
<div :class="[isBetweenHalloweenAndHalfOfNovember(new Date()) ? 'font-sans-serif-halloween' : '']">
Project AIRI
</div>
@@ -165,16 +184,28 @@ watchEffect((onCleanup) => {
<template v-if="isBetweenHalloweenAndHalfOfNovember(new Date())">
<div class="bg-icon-pattern pointer-events-none absolute inset-0 z-0 opacity-10 dark:opacity-10" :style="{ '--bg-mask-icon-pattern': `url(${homeBackgroundPatternGhost})` }" />
</template>
<template v-if="isBetweenChristmasAndHalfOfJanuary(new Date())">
<img :src="homeBackgroundChristmas20251224" class="pointer-events-none absolute inset-0 z-0 h-full w-full object-cover">
</template>
<template v-else>
<div class="bg-icon-pattern pointer-events-none absolute inset-0 z-0 opacity-10 dark:opacity-10" :style="{ '--bg-mask-icon-pattern': `url(${homeBackgroundPatternLollipop})` }" />
</template>
</ClientOnly>
<!-- Flickering red (even within the color space range) if to top in oklch (UnoCSS or tailwind css default), have to force to use srgb color space to prevent this -->
<div class="absolute bottom-0 left-0 right-0 top-0 z-2 h-80% from-transparent to-white bg-gradient-to-t dark:to-[hsl(207_15%_5%)]" style="--un-gradient-shape: to top in srgb;" />
<ClientOnly>
<!-- Flickering red (even within the color space range) if to top in oklch (UnoCSS or tailwind css default), have to force to use srgb color space to prevent this -->
<div
:class="[
'absolute bottom-0 left-0 right-0 top-0 z-2',
'from-transparent to-white bg-gradient-to-t dark:to-[hsl(207_15%_5%)]',
isBetweenChristmasAndHalfOfJanuary(new Date()) ? 'h-[40%]' : 'h-[80%]',
]"
style="--un-gradient-shape: to top in srgb;"
/>
<template v-if="isBetweenHalloweenAndHalfOfNovember(new Date())">
<ParallaxCoverHalloween20251029 />
</template>
<template v-else-if="isBetweenChristmasAndHalfOfJanuary(new Date())">
<Snowfall />
<ParallaxCoverChristmas20251224 />
</template>
<template v-else>
<ParallaxCover />
</template>
@@ -182,7 +213,7 @@ watchEffect((onCleanup) => {
</div>
<footer class="fixed bottom-3 left-1/2 z-40 flex justify-end -translate-x-1/2">
<div class="rounded-full bg-white/80 px-3 py-2 text-sm text-slate-400 backdrop-blur-md dark:bg-white/20 dark:text-slate-900" text="nowrap">
<div class="rounded-full bg-white/80 px-3 py-2 text-sm text-slate-700 backdrop-blur-md dark:bg-white/20 dark:text-slate-900" text="nowrap">
Since 2024 @ <a href="https://github.com/proj-airi">Project AIRI</a> × <a href="https://github.com/moeru-ai">萌える AI 研究会</a>
</div>
</footer>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { AnimatableObject } from 'animejs'
import { useLocalStorage } from '@vueuse/core'
import { createAnimatable } from 'animejs'
import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
import homeCover from '../assets/home-cover-2025-12-24.avif'
const surfaceRef = useTemplateRef<HTMLImageElement>('surface')
const silhouetteLayer1Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer1')
const silhouetteLayer2Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer2')
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
const DURATION = 1200
const EASE = 'outSine'
const surfaceAnimatable = shallowRef<AnimatableObject>()
const silhouetteLayer1Animatable = shallowRef<AnimatableObject>()
const silhouetteLayer2Animatable = shallowRef<AnimatableObject>()
function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
const referenceWidth = window.innerWidth
surfaceAnimatable.value?.x?.(-xOffsetRatio * 0.02 * referenceWidth)
surfaceAnimatable.value?.y?.(-yOffsetRatio * 0.02 * referenceWidth)
surfaceAnimatable.value?.z?.(0)
silhouetteLayer1Animatable.value?.x?.(0.01 * referenceWidth - yOffsetRatio * 0.015 * referenceWidth)
silhouetteLayer1Animatable.value?.y?.(0.02 * referenceWidth + xOffsetRatio * 0.015 * referenceWidth)
silhouetteLayer1Animatable.value?.z?.(0)
silhouetteLayer2Animatable.value?.x?.(0.01 * referenceWidth + yOffsetRatio * 0.01 * referenceWidth)
silhouetteLayer2Animatable.value?.y?.(-0.01 * referenceWidth - yOffsetRatio * 0.01 * referenceWidth)
silhouetteLayer2Animatable.value?.z?.(0)
}
function onMouseMove(event: MouseEvent) {
const x = event.clientX
const y = event.clientY
const xOffsetRatio = (x - window.innerWidth / 2) / window.innerWidth
const yOffsetRatio = (y - window.innerHeight / 2) / window.innerHeight
animateCover(xOffsetRatio, yOffsetRatio)
}
onMounted(() => {
const animatableConfig = {
x: DURATION,
y: DURATION,
z: 0,
ease: EASE,
}
surfaceAnimatable.value = createAnimatable(surfaceRef.value!, animatableConfig)
silhouetteLayer1Animatable.value = createAnimatable(silhouetteLayer1Ref.value!, animatableConfig)
silhouetteLayer2Animatable.value = createAnimatable(silhouetteLayer2Ref.value!, animatableConfig)
})
watchEffect((onCleanup) => {
if (shouldReduceMotion.value) {
animateCover(0, 0)
}
else {
if (!import.meta.env.SSR) {
window.addEventListener('mousemove', onMouseMove)
onCleanup(() => {
window.removeEventListener('mousemove', onMouseMove)
})
}
}
})
const maskImageURL = `url(${homeCover})`
</script>
<template>
<div
:class="[
'relative left-1/2 -translate-x-1/2 max-w-none z-1',
'w-[160%] translate-y-[35%] translate-x-[-55%] scale-100 -rotate-0 top-5rem',
'md:w-[150%] md:translate-y-[40%] md:rotate-[0deg] md:top-8dvh',
'lg:w-[95%] lg:translate-y-[-20%] lg:translate-x-[-50%] lg:rotate-[0deg] lg:scale-95 lg:top-32dvh',
'xl:translate-y-[-50%] xl:translate-x-[-50%] xl:scale-80 xl:top-18dvh',
'2xl:w-[100%] 2xl:translate-y-[-20%] 2xl:translate-x-[-50%] 2xl:rotate-[0deg] 2xl:top-16dvh',
]"
>
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
</div>
</template>
<style scoped>
.silhouette {
-webkit-mask-image: v-bind(maskImageURL);
mask-image: v-bind(maskImageURL);
-webkit-mask-mode: alpha;
mask-mode: alpha;
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center; /* Center the mask */
mask-position: center;
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<!--
Author: hyperstown <64496017+hyperstown@users.noreply.github.com>
License: MIT License
Source code: https://github.com/hyperstown/pure-snow.js
https://codepen.io/YusukeNakaya/pen/NWPqvWW
https://codepen.io/alphardex/pen/dyPorwJ
-->
<script setup lang="ts">
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { onMounted, useTemplateRef, watchEffect } from 'vue'
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
const snowContainer = useTemplateRef<HTMLDivElement>('snowContainer')
const SNOWFLAKE_COUNT = 160
const STYLE_ELEMENT_ID = 'snowfall-dynamic-css'
function randomInt(max = 100) {
return Math.floor(Math.random() * max) + 1
}
function randomIntRange(min: number, max: number) {
const ceilMin = Math.ceil(min)
const floorMax = Math.floor(max)
return Math.floor(Math.random() * (floorMax - ceilMin + 1)) + ceilMin
}
function randomBetween(min: number, max: number) {
return Math.random() * (max - min) + min
}
function getHeights() {
const bodyHeightPx = document.body.offsetHeight || window.innerHeight
const pageHeightVh = (100 * bodyHeightPx) / window.innerHeight
return { bodyHeightPx, pageHeightVh }
}
function ensureStyleElement() {
let styleEl = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = STYLE_ELEMENT_ID
document.head.appendChild(styleEl)
}
return styleEl
}
function generateSnowCSS(count: number, pageHeightVh: number) {
let rule = ''
const snowflakeName = 'snowflake'
for (let i = 1; i <= count; i++) {
const randomX = Math.random() * 100 // vw
const randomOffset = Math.random() * 10 // vw
const randomXEnd = randomX + randomOffset
const randomXEndYoyo = randomX + randomOffset / 2
const randomYoyoTime = randomBetween(0.3, 0.8)
const randomYoyoY = randomYoyoTime * pageHeightVh // vh
const randomScale = Math.random()
const fallDuration = randomIntRange(10, (pageHeightVh / 10) * 3) // s
const fallDelay = randomInt((pageHeightVh / 10) * 3) * -1 // s
const opacity = Math.random()
rule += `
.${snowflakeName}:nth-child(${i}) {
opacity: ${opacity};
transform: translate(${randomX}vw, -10px) scale(${randomScale});
animation: fall-${i} ${fallDuration}s ${fallDelay}s linear infinite;
}
@keyframes fall-${i} {
${randomYoyoTime * 100}% {
transform: translate(${randomXEnd}vw, ${randomYoyoY}vh) scale(${randomScale});
}
to {
transform: translate(${randomXEndYoyo}vw, ${pageHeightVh}vh) scale(${randomScale});
}
}
`
}
ensureStyleElement().textContent = rule
}
function clearSnow() {
const container = snowContainer.value
if (!container)
return
container.innerHTML = ''
}
function generateSnowflakes(count: number) {
const container = snowContainer.value
if (!container)
return
container.innerHTML = ''
for (let i = 0; i < count; i++) {
const flake = document.createElement('div')
flake.className = 'snowflake'
container.appendChild(flake)
}
}
function createSnow() {
const container = snowContainer.value
if (!container)
return
const count = Number(container.dataset.count || SNOWFLAKE_COUNT)
const { pageHeightVh } = getHeights()
generateSnowCSS(count, pageHeightVh)
generateSnowflakes(count)
}
onMounted(() => {
if (import.meta.env.SSR)
return
createSnow()
})
watchEffect(() => {
if (import.meta.env.SSR)
return
const handleResize = () => createSnow()
if (shouldReduceMotion.value) {
clearSnow()
snowContainer.value?.style.setProperty('display', 'none')
}
else {
snowContainer.value?.style.removeProperty('display')
createSnow()
useEventListener('resize', handleResize)
}
})
</script>
<template>
<div
ref="snowContainer"
:class="[
'docs-theme-christmas-2025-12-24-snowfall',
'pointer-events-none',
'fixed',
'inset-0',
'z-100',
'overflow-hidden',
]"
aria-hidden="true"
/>
</template>
<style>
.docs-theme-christmas-2025-12-24-snowfall .snowflake {
position: absolute;
width: 12px;
height: 12px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.35);
will-change: transform;
}
</style>
+8
View File
@@ -7,3 +7,11 @@ export function isBetweenHalloweenAndHalfOfNovember(date: Date) {
return isAfter(date, halloween) && isBefore(date, halfOfNovember)
}
export function isBetweenChristmasAndHalfOfJanuary(date: Date) {
const year = date.getFullYear()
const christmas = new Date(year, 11, 20) // December 20
const halfOfJanuary = new Date(year + 1, 0, 10) // January 10 of the next year
return isAfter(date, christmas) && isBefore(date, halfOfJanuary)
}