mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
chore: lint using react-doctor, update translations, dynamic imports
This commit is contained in:
@@ -2,7 +2,7 @@ import type { IconProps } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, HeartIcon, RocketIcon, SparkleIcon, UsersIcon, WrenchIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
@@ -13,7 +13,7 @@ type FloatingIconProps = {
|
||||
};
|
||||
|
||||
const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) => (
|
||||
<motion.div
|
||||
<m.div
|
||||
className={cn("absolute text-primary/20", className)}
|
||||
animate={{
|
||||
y: [0, -12, 0],
|
||||
@@ -28,11 +28,11 @@ const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) =
|
||||
}}
|
||||
>
|
||||
<Icon size={32} weight="duotone" />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
|
||||
const PulsingHeart = () => (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
@@ -44,7 +44,7 @@ const PulsingHeart = () => (
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
<motion.div
|
||||
<m.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.8],
|
||||
@@ -57,8 +57,8 @@ const PulsingHeart = () => (
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
);
|
||||
|
||||
type SparkleEffectProps = {
|
||||
@@ -66,7 +66,7 @@ type SparkleEffectProps = {
|
||||
};
|
||||
|
||||
const SparkleEffect = ({ className }: SparkleEffectProps) => (
|
||||
<motion.div
|
||||
<m.div
|
||||
className={cn("absolute", className)}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
@@ -80,7 +80,7 @@ const SparkleEffect = ({ className }: SparkleEffectProps) => (
|
||||
}}
|
||||
>
|
||||
<SparkleIcon size={16} weight="fill" className="text-amber-400" />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
|
||||
type FeatureCardProps = {
|
||||
@@ -91,7 +91,7 @@ type FeatureCardProps = {
|
||||
};
|
||||
|
||||
const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps) => (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="group relative flex flex-col items-center gap-3 rounded-2xl border border-border/50 bg-card/50 p-6 text-center backdrop-blur-sm transition-colors hover:border-primary/30 hover:bg-card/80"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -99,17 +99,17 @@ const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps
|
||||
transition={{ duration: 0.5, delay }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
aria-hidden="true"
|
||||
className="flex size-12 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
whileHover={{ rotate: [0, -10, 10, 0] }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Icon size={24} weight="light" />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
<h3 className="font-semibold tracking-tight">{title}</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
|
||||
export const DonationBanner = () => (
|
||||
@@ -130,7 +130,7 @@ export const DonationBanner = () => (
|
||||
|
||||
<div className="container relative px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex flex-col items-center text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -143,7 +143,7 @@ export const DonationBanner = () => (
|
||||
<SparkleEffect className="-inset-s-3 bottom-0" />
|
||||
</div>
|
||||
|
||||
<motion.h2
|
||||
<m.h2
|
||||
className="mb-6 font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -151,9 +151,9 @@ export const DonationBanner = () => (
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Trans>Support Reactive Resume</Trans>
|
||||
</motion.h2>
|
||||
</m.h2>
|
||||
|
||||
<motion.p
|
||||
<m.p
|
||||
className="max-w-3xl text-base text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -164,8 +164,8 @@ export const DonationBanner = () => (
|
||||
Reactive Resume is a free and open-source project, built with love and maintained by me and a community of
|
||||
contributors. Your donations help keep the lights on and the code flowing.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</m.p>
|
||||
</m.div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="mx-auto my-12 grid max-w-5xl gap-8 sm:grid-cols-3">
|
||||
@@ -190,7 +190,7 @@ export const DonationBanner = () => (
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex flex-col items-center justify-center gap-4 sm:flex-row"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -222,10 +222,10 @@ export const DonationBanner = () => (
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
{/* Footer note */}
|
||||
<motion.p
|
||||
<m.p
|
||||
className="mt-8 text-center text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
@@ -237,7 +237,7 @@ export const DonationBanner = () => (
|
||||
<br />
|
||||
Thank you for your support!
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</m.p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
@@ -68,7 +68,7 @@ export function FAQ() {
|
||||
id="frequently-asked-questions"
|
||||
className="flex flex-col gap-x-16 gap-y-6 p-4 md:p-8 lg:flex-row lg:gap-x-18 xl:py-16"
|
||||
>
|
||||
<motion.h2
|
||||
<m.h2
|
||||
className={cn(
|
||||
"flex-1 font-semibold text-2xl tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
|
||||
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
|
||||
@@ -83,9 +83,9 @@ export function FAQ() {
|
||||
<span>Asked</span>
|
||||
<span>Questions</span>
|
||||
</Trans>
|
||||
</motion.h2>
|
||||
</m.h2>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@@ -97,7 +97,7 @@ export function FAQ() {
|
||||
<FAQItemComponent key={item.question} item={item} index={index} />
|
||||
))}
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ type FAQItemComponentProps = {
|
||||
|
||||
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="will-change-[transform,opacity] last:border-b"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -120,6 +120,6 @@ function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
|
||||
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ShieldCheckIcon,
|
||||
TranslateIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
@@ -133,7 +133,7 @@ const getFeatures = (): Feature[] => [
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className={cn(
|
||||
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
@@ -162,7 +162,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
<h3 className="font-semibold text-base tracking-tight transition-colors group-hover:text-primary">{title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function Features() {
|
||||
return (
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
<m.div
|
||||
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -189,7 +189,7 @@ export function Features() {
|
||||
powered by open source, and completely free forever.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 border-t xl:grid-cols-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -46,7 +46,7 @@ const socialLinks: SocialLink[] = [
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<motion.footer
|
||||
<m.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -60,7 +60,7 @@ export function Footer() {
|
||||
<BrandIcon variant="logo" className="size-10" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-bold text-lg tracking-tight">Reactive Resume</h2>
|
||||
<h2 className="font-semibold text-lg tracking-tight">Reactive Resume</h2>
|
||||
<p className="max-w-xs text-muted-foreground text-sm leading-relaxed">
|
||||
<Trans>
|
||||
A free and open-source resume builder that simplifies the process of creating, updating, and sharing
|
||||
@@ -103,7 +103,7 @@ export function Footer() {
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</motion.footer>
|
||||
</m.footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
|
||||
<Trans>(opens in new tab)</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion, useMotionValue, useSpring } from "motion/react";
|
||||
import { m, useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -41,7 +41,7 @@ export function Header() {
|
||||
}, [y]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
<m.header
|
||||
style={{ y: springY }}
|
||||
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
@@ -80,6 +80,6 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
</m.header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, BookIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
@@ -16,7 +16,7 @@ export function Hero() {
|
||||
>
|
||||
<Spotlight />
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -28,8 +28,6 @@ export function Hero() {
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
// @ts-expect-error - typescript doesn't know about fetchPriority for video elements
|
||||
fetchPriority="high"
|
||||
src="/videos/timelapse.mp4"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none size-full rounded-md border object-cover"
|
||||
@@ -40,11 +38,11 @@ export function Hero() {
|
||||
className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-40% via-transparent to-background"
|
||||
/>
|
||||
</CometCard>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<div className="relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 xs:px-0 text-center">
|
||||
{/* Badge */}
|
||||
<motion.a
|
||||
<m.a
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -59,10 +57,10 @@ export function Hero() {
|
||||
<SparkleIcon aria-hidden="true" className="size-3.5" weight="fill" />
|
||||
<Trans>What's new in the latest version?</Trans>
|
||||
</Badge>
|
||||
</motion.a>
|
||||
</m.a>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.div
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -70,14 +68,14 @@ export function Hero() {
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium text-muted-foreground tracking-tight md:text-lg">Finally,</p>
|
||||
<h1 className="mt-1 font-bold text-4xl tracking-tight md:text-5xl lg:text-6xl">
|
||||
<h1 className="mt-1 font-semibold text-4xl tracking-tight md:text-5xl lg:text-6xl">
|
||||
A free and open-source resume builder
|
||||
</h1>
|
||||
</Trans>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
<m.p
|
||||
className="max-w-xl text-base text-muted-foreground leading-relaxed will-change-[transform,opacity] md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -87,10 +85,10 @@ export function Hero() {
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
and sharing your resume.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</m.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -128,11 +126,11 @@ export function Hero() {
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - decorative */}
|
||||
<motion.div
|
||||
<m.div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
|
||||
@@ -140,14 +138,14 @@ export function Hero() {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.25, duration: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<m.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</m.div>
|
||||
</m.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { TextMaskEffect } from "@/components/animation/text-mask";
|
||||
|
||||
export function Prefooter() {
|
||||
@@ -14,14 +14,14 @@ export function Prefooter() {
|
||||
<div className="relative space-y-8">
|
||||
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
|
||||
|
||||
<motion.div
|
||||
<m.div
|
||||
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<h2 className="font-bold text-2xl tracking-tight md:text-4xl">
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</h2>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Prefooter() {
|
||||
accessible to a broader audience, and the people who've donated to support its continued development.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FileTextIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { CountUp } from "@/components/animation/count-up";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
@@ -37,7 +37,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
const Icon = statistic.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="group relative flex flex-col items-center justify-center gap-y-4 border-r border-b p-8 transition-colors last:border-e-0 hover:bg-secondary/30 sm:border-b-0 xl:py-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -46,7 +46,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
<m.div
|
||||
className="absolute inset-s-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-primary/2"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
@@ -54,18 +54,18 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
transition={{ duration: 0.8, delay: index * 0.1 + 0.2 }}
|
||||
>
|
||||
<Icon size={180} weight="thin" />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
<m.div
|
||||
aria-hidden="true"
|
||||
className="relative rounded-full bg-primary/10 p-3 text-primary"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||
>
|
||||
<Icon size={24} weight="thin" />
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
{/* Value */}
|
||||
<CountUp
|
||||
@@ -78,7 +78,7 @@ function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
|
||||
{/* Label */}
|
||||
<p className="relative font-medium text-base text-muted-foreground tracking-tight">{statistic.label}</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
|
||||
@@ -8,9 +8,14 @@ type TemplateItemProps = {
|
||||
metadata: TemplateMetadata;
|
||||
};
|
||||
|
||||
type TemplateMarqueeItem = {
|
||||
id: string;
|
||||
metadata: TemplateMetadata;
|
||||
};
|
||||
|
||||
function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="group relative shrink-0 will-change-transform"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.06, zIndex: 20 }}
|
||||
@@ -31,22 +36,21 @@ function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
{/* Shine effect on hover */}
|
||||
<div className="pointer-events-none absolute inset-0 -translate-x-full rotate-12 bg-linear-to-r from-transparent via-white/10 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
type MarqueeRowProps = {
|
||||
templates: Array<[string, TemplateMetadata]>;
|
||||
rowId: string;
|
||||
templates: TemplateMarqueeItem[];
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowProps) {
|
||||
function MarqueeRow({ templates, direction, duration = 40 }: MarqueeRowProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex gap-x-4 will-change-transform sm:gap-x-6"
|
||||
animate={{ x: animateX }}
|
||||
transition={{
|
||||
@@ -58,13 +62,19 @@ function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowPr
|
||||
},
|
||||
}}
|
||||
>
|
||||
{templates.map(([template, metadata], index) => (
|
||||
<TemplateItem key={`${rowId}-${template}-${index}`} metadata={metadata} />
|
||||
{templates.map(({ id, metadata }) => (
|
||||
<TemplateItem key={id} metadata={metadata} />
|
||||
))}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
const createMarqueeItems = (entries: Array<[string, TemplateMetadata]>, rowId: string): TemplateMarqueeItem[] =>
|
||||
entries.flatMap(([template, metadata]) => [
|
||||
{ id: `${rowId}-${template}-primary`, metadata },
|
||||
{ id: `${rowId}-${template}-repeat`, metadata },
|
||||
]);
|
||||
|
||||
export function Templates() {
|
||||
// Split templates into two rows and duplicate for seamless infinite scroll
|
||||
const { row1, row2 } = useMemo(() => {
|
||||
@@ -75,14 +85,14 @@ export function Templates() {
|
||||
|
||||
// Duplicate each row for seamless scrolling
|
||||
return {
|
||||
row1: [...firstHalf, ...firstHalf],
|
||||
row2: [...secondHalf, ...secondHalf],
|
||||
row1: createMarqueeItems(firstHalf, "row1"),
|
||||
row2: createMarqueeItems(secondHalf, "row2"),
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
|
||||
<motion.div
|
||||
<m.div
|
||||
className="space-y-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -99,16 +109,16 @@ export function Templates() {
|
||||
personalities. Reactive Resume currently offers 12 templates, with more on the way.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<div className="relative mt-8 -rotate-3 py-8 sm:-rotate-4 lg:mt-0 lg:-rotate-5">
|
||||
{/* Marquee container with minimum height */}
|
||||
<div className="flex min-h-[280px] flex-col gap-y-4 sm:min-h-[320px] sm:gap-y-6 md:min-h-[380px] lg:min-h-[420px]">
|
||||
{/* First row - moves left to right */}
|
||||
<MarqueeRow templates={row1} rowId="row1" direction="left" duration={45} />
|
||||
<MarqueeRow templates={row1} direction="left" duration={45} />
|
||||
|
||||
{/* Second row - moves right to left (opposite direction) */}
|
||||
<MarqueeRow templates={row2} rowId="row2" direction="right" duration={50} />
|
||||
<MarqueeRow templates={row2} direction="right" duration={50} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { QuotesIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const email = "hello@amruthpillai.com";
|
||||
@@ -35,7 +35,7 @@ type TestimonialCardProps = {
|
||||
|
||||
function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="group relative flex w-full flex-col overflow-hidden text-pretty rounded-2xl border bg-card p-4 will-change-transform"
|
||||
initial={{ scale: 1, boxShadow: "0 0 20px 0 rgba(0, 0, 0, 0)" }}
|
||||
whileHover={{ scale: 1.2, zIndex: 100, boxShadow: "0 0 40px 0 rgba(0, 0, 0, 0.5)" }}
|
||||
@@ -46,54 +46,62 @@ function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
className="absolute -right-2 -bottom-4 size-18 opacity-10 transition-[bottom] duration-200 group-hover:-bottom-16"
|
||||
/>
|
||||
<p className="flex-1 text-muted-foreground leading-relaxed">{testimonial}</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
type TestimonialColumnProps = {
|
||||
columnId: string;
|
||||
id: string;
|
||||
testimonials: string[];
|
||||
};
|
||||
|
||||
function TestimonialColumn({ columnId, testimonials }: TestimonialColumnProps) {
|
||||
function TestimonialColumn({ id, testimonials }: TestimonialColumnProps) {
|
||||
return (
|
||||
<div className="flex w-[320px] shrink-0 flex-col gap-y-4 sm:w-[360px] md:w-[400px]">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<TestimonialCard key={`${columnId}-${index}`} testimonial={testimonial} />
|
||||
{testimonials.map((testimonial) => (
|
||||
<TestimonialCard key={`${id}-${testimonial}`} testimonial={testimonial} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TestimonialColumnData = {
|
||||
id: string;
|
||||
testimonials: string[];
|
||||
};
|
||||
|
||||
type MarqueeMasonryProps = {
|
||||
columns: string[][];
|
||||
columns: TestimonialColumnData[];
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
const marqueeColumns = [...columns, ...columns];
|
||||
const marqueeColumns = columns.flatMap((column) => [
|
||||
{ ...column, id: `${column.id}-primary` },
|
||||
{ ...column, id: `${column.id}-repeat` },
|
||||
]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<m.div
|
||||
className="flex items-start gap-x-4 will-change-transform"
|
||||
animate={{ x: animateX }}
|
||||
transition={{ x: { repeat: Number.POSITIVE_INFINITY, repeatType: "loop", duration, ease: "linear" } }}
|
||||
>
|
||||
{marqueeColumns.map((column, index) => (
|
||||
<TestimonialColumn key={index} columnId={`column-${index}`} testimonials={column} />
|
||||
{marqueeColumns.map((column) => (
|
||||
<TestimonialColumn key={column.id} id={column.id} testimonials={column.testimonials} />
|
||||
))}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Testimonials() {
|
||||
const columns = useMemo(() => {
|
||||
const columns: string[][] = [];
|
||||
const columns: TestimonialColumnData[] = [];
|
||||
|
||||
for (let index = 0; index < testimonials.length; index += 2) {
|
||||
columns.push(testimonials.slice(index, index + 2));
|
||||
columns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
|
||||
}
|
||||
|
||||
return columns;
|
||||
@@ -101,8 +109,8 @@ export function Testimonials() {
|
||||
|
||||
return (
|
||||
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
|
||||
<motion.div
|
||||
className="mb-10 flex flex-col items-center space-y-4 px-4 text-center md:px-8"
|
||||
<m.div
|
||||
className="mb-10 flex flex-col items-center gap-y-4 px-4 text-center md:px-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@@ -128,7 +136,7 @@ export function Testimonials() {
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</m.div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Left fade */}
|
||||
|
||||
Reference in New Issue
Block a user