www/src/components/Features.astro
2025-05-14 09:43:02 +12:00

323 lines
8.7 KiB
Text

---
import { motion } from 'motion/react'
import { getTitleAnimation } from '~/animations'
import Description from '~/components/Description.astro'
import CompactModeVideo from '~/assets/CompactMode.webm'
import GlanceVideo from '~/assets/Glance.webm'
import SplitViewsVideo from '~/assets/SplitViews.webm'
import WorkspacesVideo from '~/assets/Workspaces.webm'
import { getLocale, getUI } from '~/utils/i18n'
import Video from './Video.astro'
const locale = getLocale(Astro)
const {
routes: {
index: { features },
},
} = getUI(locale)
const {
title1 = features.title1,
title2 = features.title2,
title3 = features.title3,
} = Astro.props
const descriptions = Object.values(features.featureTabs).map(
(tab) => tab.description,
)
---
<section
id="Features"
class="relative flex w-full flex-col px-4 text-start md:px-24 lg:mx-auto lg:w-3/4 lg:px-0 lg:py-36"
>
<Description class="mb-2 text-6xl font-bold">
<motion.span client:load {...getTitleAnimation(0.2)}>
{title1}
</motion.span>
<motion.span client:load {...getTitleAnimation(0.4)}>
{title2}
</motion.span>
<motion.span client:load {...getTitleAnimation(0.6)}>
{title3}
</motion.span>
</Description>
<motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2">
{features.description}
</motion.p>
<div
class="mb-12 mt-6 flex flex-col gap-6 lg:flex-row lg:justify-between lg:gap-2"
>
<div class="flex w-full flex-col lg:w-1/3">
<!-- Mobile tabs -->
<div class="flex gap-2 overflow-x-auto overflow-y-clip lg:hidden">
<motion.button
client:load
{...getTitleAnimation()}
class="feature-tab whitespace-nowrap"
data-active="true"
>
{features.featureTabs.workspaces.title}
</motion.button>
<motion.button
client:load
{...getTitleAnimation(0.2)}
class="feature-tab whitespace-nowrap"
>
{features.featureTabs.compactMode.title}
</motion.button>
<motion.button
client:load
{...getTitleAnimation(0.4)}
class="feature-tab whitespace-nowrap"
>
{features.featureTabs.glance.title}
</motion.button>
<motion.button
client:load
{...getTitleAnimation(0.6)}
class="feature-tab whitespace-nowrap"
>
{features.featureTabs.splitView.title}
</motion.button>
</div>
<!-- Desktop features list -->
<div id="features-list" class="hidden lg:flex lg:flex-col lg:gap-3">
<motion.div
client:load
{...getTitleAnimation(0.8)}
className="feature"
data-active="true"
>
<Description class="text-2xl font-bold">
{features.featureTabs.workspaces.title}
</Description>
<Description>
{features.featureTabs.workspaces.description}
</Description>
</motion.div>
<motion.div client:load {...getTitleAnimation(1)} className="feature">
<Description class="text-2xl font-bold">
{features.featureTabs.compactMode.title}
</Description>
<Description>
{features.featureTabs.compactMode.description}
</Description>
</motion.div>
<motion.div client:load {...getTitleAnimation(1.2)} className="feature">
<Description class="text-2xl font-bold">
{features.featureTabs.glance.title}
</Description>
<Description>
{features.featureTabs.glance.description}
</Description>
</motion.div>
<motion.div client:load {...getTitleAnimation(1.4)} className="feature">
<Description class="text-2xl font-bold">
{features.featureTabs.splitView.title}
</Description>
<Description>
{features.featureTabs.splitView.description}
</Description>
</motion.div>
</div>
<!-- Mobile description -->
<div
class="feature-description mt-4 lg:hidden"
data-descriptions={descriptions}
>
</div>
</div>
<div class="relative w-full lg:w-3/5">
<div class="video-stack relative h-full w-full">
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
src={WorkspacesVideo}
data-active="true"
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
src={CompactModeVideo}
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
src={GlanceVideo}
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
src={SplitViewsVideo}
/>
</div>
</div>
</div>
</section>
<script>
const features = document.querySelectorAll(
'.feature, .feature-tab',
) as NodeListOf<HTMLElement>
// Set initial description
const descriptionEl = document.querySelector(
'.feature-description',
) as HTMLDivElement
const descriptions = descriptionEl?.dataset.descriptions?.split(',')
if (descriptionEl && descriptions) {
descriptionEl.textContent = descriptions[0]
}
function changeToFeature({
target,
}: {
target: HTMLElement | undefined | null
}) {
target = target?.closest('.feature, .feature-tab')
if (!target) return
const index = Array.from(features).indexOf(target) % 4
if (index === -1) return
// Update both mobile and desktop elements
features.forEach((f, i) => {
if (i % 4 === index) {
f.setAttribute('data-active', 'true')
} else {
f.removeAttribute('data-active')
}
})
// Update mobile description
const descriptionEl = document.querySelector('.feature-description')
if (descriptionEl && descriptions) {
descriptionEl.textContent = descriptions[index]
}
const videos = document.querySelectorAll(
'.feature-video',
) as NodeListOf<HTMLVideoElement>
videos.forEach((vid, i) => {
const yOffset = (i - index) * 20
const zOffset = i === index ? 0 : -100 - Math.abs(i - index) * 50
const scale = i === index ? 1 : 0.95
const rotation = (i - index) * 3
if (i === index) {
vid.setAttribute('data-active', 'true')
vid.style.opacity = '1'
vid.style.transform = `translate3d(-50%, 0, 0) scale(${scale})`
vid.style.zIndex = '10'
vid.currentTime = 0
vid.play()
} else {
vid.removeAttribute('data-active')
vid.style.transform = `translate3d(-50%, ${yOffset}px, ${zOffset}px)
rotate3d(1, 0, 0, ${rotation}deg)
scale(${scale})`
vid.style.zIndex = String(1 - Math.abs(i - index))
vid.pause()
}
})
}
for (const feature of features) {
feature.addEventListener('click', changeToFeature as any)
}
changeToFeature({ target: features[0] as any })
</script>
<style>
.feature {
@apply w-full cursor-pointer select-none rounded-lg p-4 opacity-0 hover:bg-subtle;
transition: background 0.2s ease-in-out;
&[data-active='true'] {
@apply bg-subtle;
}
}
.feature-tab {
@apply rounded-lg px-4 py-2 text-lg font-medium opacity-0 hover:bg-subtle;
transition: background 0.2s ease-in-out;
&[data-active='true'] {
@apply bg-subtle;
}
}
.feature-description {
@apply px-4 text-sm text-gray-600 dark:text-gray-300;
}
.video-stack {
@apply aspect-video;
perspective: 2000px;
transform-style: preserve-3d;
position: sticky;
top: 0;
}
.feature-video {
@apply left-1/2 hidden transform rounded-3xl shadow-md lg:absolute lg:mx-auto lg:block lg:w-full dark:opacity-80;
max-width: 800px;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
top: 0;
transform-origin: top center;
backface-visibility: hidden;
will-change: transform, opacity;
transform: translate3d(-50%, 0, -100px) scale(0.95);
}
/* Dont animate translation on small screens */
@media (max-width: 1024px) {
.feature-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transform: none !important;
display: none;
object-fit: cover;
&[data-active='true'] {
display: block;
opacity: 1;
}
}
.video-stack {
@apply overflow-hidden rounded-xl;
position: relative;
width: 100%;
aspect-ratio: 16/9;
}
}
</style>