mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(docs): Merry Christmas 2025 🎄
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user