* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
@@ -0,0 +1,243 @@
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 { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
type FloatingIconProps = {
icon: React.ElementType;
className?: string;
delay?: number;
};
const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) => (
<motion.div
className={cn("absolute text-primary/20", className)}
animate={{
y: [0, -12, 0],
rotate: [0, 5, -5, 0],
scale: [1, 1.1, 1],
}}
transition={{
duration: 4,
repeat: Number.POSITIVE_INFINITY,
delay,
ease: "easeInOut",
}}
>
<Icon size={32} weight="duotone" />
</motion.div>
);
const PulsingHeart = () => (
<motion.div
className="relative inline-flex items-center justify-center"
animate={{
scale: [1, 1.15, 1],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
<HeartIcon size={48} weight="fill" className="text-rose-500" />
<motion.div
className="absolute inset-0 flex items-center justify-center"
animate={{
scale: [1, 1.8],
opacity: [0.6, 0],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeOut",
}}
>
<HeartIcon size={48} weight="fill" className="text-rose-500" />
</motion.div>
</motion.div>
);
type SparkleEffectProps = {
className?: string;
};
const SparkleEffect = ({ className }: SparkleEffectProps) => (
<motion.div
className={cn("absolute", className)}
animate={{
scale: [0, 1, 0],
opacity: [0, 1, 0],
rotate: [0, 180],
}}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
<SparkleIcon size={16} weight="fill" className="text-amber-400" />
</motion.div>
);
type FeatureCardProps = {
icon: React.ElementType<IconProps>;
title: string;
description: string;
delay: number;
};
const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps) => (
<motion.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 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
whileHover={{ y: -4 }}
>
<motion.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>
<h3 className="font-semibold tracking-tight">{title}</h3>
<p className="text-muted-foreground leading-relaxed">{description}</p>
</motion.div>
);
export const DonationBanner = () => (
<section className="relative overflow-hidden bg-linear-to-b from-background via-primary/2 to-background py-24">
{/* Background decorative elements */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<FloatingIcon icon={HeartIcon} className="top-[20%] left-[10%]" delay={0} />
<FloatingIcon icon={SparkleIcon} className="top-[15%] right-[15%]" delay={0.5} />
<FloatingIcon icon={UsersIcon} className="bottom-[25%] left-[8%]" delay={1} />
<FloatingIcon icon={WrenchIcon} className="right-[12%] bottom-[30%]" delay={1.5} />
<FloatingIcon icon={RocketIcon} className="top-[35%] right-[25%]" delay={2} />
<FloatingIcon icon={HeartIcon} className="bottom-[20%] left-[20%]" delay={2.5} />
{/* Gradient Orbs */}
<div className="absolute -inset-s-32 top-1/4 size-64 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute -inset-e-32 bottom-1/4 size-64 rounded-full bg-rose-500/5 blur-3xl" />
</div>
<div className="container relative px-8">
{/* Header */}
<motion.div
className="flex flex-col items-center text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<div aria-hidden="true" className="relative mb-6">
<PulsingHeart />
<SparkleEffect className="-inset-e-4 -top-2" />
<SparkleEffect className="-inset-s-3 bottom-0" />
</div>
<motion.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 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Trans>Support Reactive Resume</Trans>
</motion.h2>
<motion.p
className="max-w-3xl text-base text-muted-foreground leading-relaxed"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Trans>
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>
{/* Feature cards */}
<div className="mx-auto my-12 grid max-w-5xl gap-8 sm:grid-cols-3">
<FeatureCard
icon={RocketIcon}
title={t`Long-term Sustainability`}
description={t`Your support ensures the project remains free and accessible for everyone, now and in the future.`}
delay={0.3}
/>
<FeatureCard
icon={WrenchIcon}
title={t`Ongoing Maintenance`}
description={t`Contributions fund bug fixes, security updates, and continuous improvements to keep the app running smoothly.`}
delay={0.4}
/>
<FeatureCard
icon={UsersIcon}
title={t`Grow the Team`}
description={t`Help me bring more experienced contributors on board, reducing the burden on a single maintainer and accelerating development.`}
delay={0.5}
/>
</div>
{/* CTA Buttons */}
<motion.div
className="flex flex-col items-center justify-center gap-4 sm:flex-row"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.6 }}
>
<Button
size="lg"
nativeButton={false}
className="h-11 gap-2 px-6"
render={
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
Open Collective
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
}
/>
<Button
size="lg"
nativeButton={false}
className="h-11 gap-2 px-6"
render={
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener">
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
GitHub Sponsors
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
}
/>
</motion.div>
{/* Footer note */}
<motion.p
className="mt-8 text-center text-muted-foreground leading-relaxed"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.8 }}
>
<Trans>
Every contribution, big or small, makes a huge difference to the project.
<br />
Thank you for your support!
</Trans>
</motion.p>
</div>
</section>
);
+129
View File
@@ -0,0 +1,129 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { motion } 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";
const crowdinUrl = "https://crowdin.com/project/reactive-resume";
type FAQItemData = {
question: string;
answer: React.ReactNode;
};
const getFaqItems = (): FAQItemData[] => [
{
question: t`Is Reactive Resume really free?`,
answer: t`Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.`,
},
{
question: t`How is my data protected?`,
answer: t`Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.`,
},
{
question: t`Can I export my resume to PDF?`,
answer: t`Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.`,
},
{
question: t`Is Reactive Resume available in multiple languages?`,
answer: (
<Trans>
Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings
page, or using the language switcher in the top right corner. If you don't see your language, or you would like
to improve the existing translations, you can{" "}
<a
href={crowdinUrl}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
>
contribute to the translations on Crowdin
<span className="sr-only">
<Trans comment="Screen reader hint indicating the FAQ translation contribution link opens in a new browser tab">
{" "}
(opens in new tab)
</Trans>
</span>
</a>
.
</Trans>
),
},
{
question: t`What makes Reactive Resume different from other resume builders?`,
answer: t`Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.`,
},
{
question: t`Can I customize the templates?`,
answer: t`Yes! Every template is fully customizable. You can change colors, fonts, spacing, and even write custom CSS for complete control over your resume's appearance.`,
},
{
question: t`How do I share my resume?`,
answer: t`You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!`,
},
];
export function FAQ() {
const faqItems = getFaqItems();
return (
<section
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
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",
)}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
>
<Trans context="Home page FAQ section heading with each word visually separated into individual spans">
<span>Frequently</span>
<span>Asked</span>
<span>Questions</span>
</Trans>
</motion.h2>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.08 }}
className="max-w-2xl flex-2 will-change-[transform,opacity] lg:ml-auto 2xl:max-w-3xl"
>
<Accordion multiple>
{faqItems.map((item, index) => (
<FAQItemComponent key={item.question} item={item} index={index} />
))}
</Accordion>
</motion.div>
</section>
);
}
type FAQItemComponentProps = {
item: FAQItemData;
index: number;
};
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
return (
<motion.div
className="will-change-[transform,opacity] last:border-b"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
>
<AccordionItem value={item.question} className="group border-t">
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
</AccordionItem>
</motion.div>
);
}
@@ -0,0 +1,202 @@
import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CloudArrowUpIcon,
CodeSimpleIcon,
CurrencyDollarIcon,
DatabaseIcon,
DotsThreeIcon,
FilePdfIcon,
FilesIcon,
GithubLogoIcon,
GlobeIcon,
KeyIcon,
LayoutIcon,
LockSimpleIcon,
PaletteIcon,
ProhibitIcon,
ShieldCheckIcon,
TranslateIcon,
} from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useMemo } from "react";
import { cn } from "@reactive-resume/utils/style";
type Feature = {
id: string;
icon: Icon;
title: string;
description: string;
};
type FeatureCardProps = Feature;
const getFeatures = (): Feature[] => [
{
id: "free",
icon: CurrencyDollarIcon,
title: t`Free`,
description: t`Completely free, forever, no hidden costs.`,
},
{
id: "open-source",
icon: GithubLogoIcon,
title: t`Open Source`,
description: t`By the community, for the community.`,
},
{
id: "no-ads",
icon: ProhibitIcon,
title: t`No Advertising, No Tracking`,
description: t`For a secure and distraction-free experience.`,
},
{
id: "instant-generation",
icon: FilePdfIcon,
title: t`Instant Generation`,
description: t`Export your resume to PDF instantly, without any waiting or delays.`,
},
{
id: "data-security",
icon: DatabaseIcon,
title: t`Data Security`,
description: t`Your data is secure, and never shared or sold to anyone.`,
},
{
id: "self-host",
icon: CloudArrowUpIcon,
title: t`Self-Host with Docker`,
description: t`You also have the option to deploy on your own servers using the Docker image.`,
},
{
id: "languages",
icon: TranslateIcon,
title: t`Multilingual`,
description: t`Available in multiple languages. If you would like to contribute, check out Crowdin.`,
},
{
id: "auth",
icon: KeyIcon,
title: t`One-Click Sign-In`,
description: t`Sign in with GitHub, Google or a custom OAuth provider.`,
},
{
id: "2fa",
icon: ShieldCheckIcon,
title: t`Passkeys & 2FA`,
description: t`Enhance the security of your account with additional layers of protection.`,
},
{
id: "unlimited-resumes",
icon: FilesIcon,
title: t`Unlimited Resumes`,
description: t`Create as many resumes as you want, without limits.`,
},
{
id: "design",
icon: PaletteIcon,
title: t`Flexibility`,
description: t`Personalize your resume with any colors, fonts or designs, and make it your own.`,
},
{
id: "templates",
icon: LayoutIcon,
title: t`12+ Templates`,
description: t`Beautiful templates to choose from, with more on the way.`,
},
{
id: "public",
icon: GlobeIcon,
title: t`Shareable Links`,
description: t`Share your resume with a public URL, and let others view it.`,
},
{
id: "password-protection",
icon: LockSimpleIcon,
title: t`Password Protection`,
description: t`Protect your resume with a password, and let only people with the password view it.`,
},
{
id: "api-access",
icon: CodeSimpleIcon,
title: t`API Access`,
description: t`Access your resumes and data programmatically using the API.`,
},
{
id: "more",
icon: DotsThreeIcon,
title: t`And many more...`,
description: t`New features are constantly being added and improved, so be sure to check back often.`,
},
];
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
return (
<motion.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",
"hover:bg-secondary/30",
)}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
{/* Hover gradient overlay */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-linear-to-br from-primary/5 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
{/* Icon */}
<div aria-hidden="true" className="relative">
<div className="inline-flex rounded-md bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
<Icon size={24} weight="thin" />
</div>
</div>
{/* Content */}
<div className="relative flex flex-col gap-y-1.5">
<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>
);
}
export function Features() {
const features = useMemo(() => getFeatures(), []);
return (
<section id="features">
{/* Header */}
<motion.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 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
>
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
<Trans>Features</Trans>
</h2>
<p className="max-w-2xl text-muted-foreground leading-relaxed">
<Trans>
Everything you need to create, customize, and share professional resumes. Built with privacy in mind,
powered by open source, and completely free forever.
</Trans>
</p>
</motion.div>
{/* Features Grid */}
<div className="grid grid-cols-1 xs:grid-cols-2 border-t xl:grid-cols-4">
{features.map((feature) => (
<FeatureCard key={feature.id} {...feature} />
))}
</div>
</section>
);
}
@@ -0,0 +1,153 @@
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 { useState } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { Copyright } from "@/components/ui/copyright";
type FooterLinkItem = {
url: string;
label: string;
};
type FooterLinkGroupProps = {
title: string;
links: FooterLinkItem[];
};
type SocialLink = {
url: string;
label: string;
icon: Icon;
};
const getResourceLinks = (): FooterLinkItem[] => [
{ url: "https://docs.rxresu.me", label: t`Documentation` },
{ url: "https://opencollective.com/reactive-resume", label: t`Sponsorships` },
{ url: "https://github.com/amruthpillai/reactive-resume", label: t`Source Code` },
{ url: "https://docs.rxresu.me/changelog", label: t`Changelog` },
];
const getCommunityLinks = (): FooterLinkItem[] => [
{ url: "https://github.com/amruthpillai/reactive-resume/issues", label: t`Report an issue` },
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
{ url: "https://discord.gg/aSyA5ZSxpb", label: t`Discord` },
];
const socialLinks: SocialLink[] = [
{ url: "https://github.com/amruthpillai/reactive-resume", label: t`GitHub`, icon: GithubLogoIcon },
{ url: "https://linkedin.com/in/amruthpillai", label: t`LinkedIn`, icon: LinkedinLogoIcon },
{ url: "https://x.com/KingOKings", label: t`X (Twitter)`, icon: XLogoIcon },
];
export function Footer() {
return (
<motion.footer
id="footer"
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
{/* Brand Column */}
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
<BrandIcon variant="logo" className="size-10" />
<div className="space-y-2">
<h2 className="font-bold 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
your resume.
</Trans>
</p>
</div>
{/* Social Links */}
<div className="flex items-center gap-2 pt-2">
{socialLinks.map((social) => (
<Button
key={social.label}
size="icon-sm"
variant="ghost"
nativeButton={false}
render={
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${social.label} (${t`opens in new tab`})`}
>
<social.icon aria-hidden="true" size={18} />
</a>
}
/>
))}
</div>
</div>
{/* Resources Column */}
<FooterLinkGroup title={t`Resources`} links={getResourceLinks()} />
{/* Community Column */}
<FooterLinkGroup title={t`Community`} links={getCommunityLinks()} />
{/* Copyright Column */}
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
<Copyright />
</div>
</div>
</motion.footer>
);
}
function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
return (
<div className="space-y-4">
<h2 className="font-medium text-muted-foreground text-sm tracking-tight">{title}</h2>
<ul className="space-y-3">
{links.map((link) => (
<FooterLink key={link.url} url={link.url} label={link.label} />
))}
</ul>
</div>
);
}
function FooterLink({ url, label }: FooterLinkItem) {
const [isHovered, setIsHovered] = useState(false);
return (
<li className="relative">
<a
href={url}
target="_blank"
rel="noopener"
className="relative inline-block text-sm transition-colors hover:text-foreground"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{label}
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
<motion.div
aria-hidden="true"
initial={{ width: 0, opacity: 0 }}
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
/>
</a>
</li>
);
}
@@ -0,0 +1,85 @@
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 { useEffect, useRef } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { GithubStarsButton } from "@/components/input/github-stars-button";
import { LocaleCombobox } from "@/components/locale/combobox";
import { ThemeToggleButton } from "@/components/theme/toggle-button";
export function Header() {
const y = useMotionValue(0);
const lastScroll = useRef(0);
const ticking = useRef(false);
const springY = useSpring(y, { stiffness: 300, damping: 40 });
useEffect(() => {
if (typeof window === "undefined") return;
function onScroll() {
const current = window.scrollY ?? 0;
if (!ticking.current) {
window.requestAnimationFrame(() => {
if (current > 32 && current > lastScroll.current) {
// Scrolling down, hide
y.set(-100);
} else {
// Scrolling up, show
y.set(0);
}
lastScroll.current = current;
ticking.current = false;
});
ticking.current = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [y]);
return (
<motion.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 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
<nav aria-label={t`Main navigation`} className="container mx-auto flex items-center gap-x-4 p-3 lg:px-12">
<Link to="/" className="transition-opacity hover:opacity-80" aria-label={t`Reactive Resume - Go to homepage`}>
<BrandIcon className="size-10" />
</Link>
<div className="ml-auto flex items-center gap-x-2">
<LocaleCombobox
render={
<Button size="icon" variant="ghost" aria-label={t`Change language`}>
<TranslateIcon />
</Button>
}
/>
<ThemeToggleButton />
<div className="hidden items-center gap-x-4 sm:flex">
<GithubStarsButton />
<Button
size="icon"
nativeButton={false}
aria-label={t`Go to dashboard`}
render={
<Link to="/dashboard">
<ArrowRightIcon aria-hidden="true" />
</Link>
}
/>
</div>
</div>
</nav>
</motion.header>
);
}
@@ -0,0 +1,153 @@
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 { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { CometCard } from "@/components/animation/comet-card";
import { Spotlight } from "@/components/animation/spotlight";
export function Hero() {
return (
<section
id="hero"
className="relative flex min-h-svh w-full flex-col items-center justify-center overflow-hidden border-b py-24"
>
<Spotlight />
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.1, ease: "easeOut" }}
>
<CometCard glareOpacity={0} className="relative -mb-12 3xl:max-w-7xl max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
<video
loop
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"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-40% via-transparent to-background"
/>
</CometCard>
</motion.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
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.55 }}
whileHover={{ y: -2, scale: 1.01 }}
whileTap={{ scale: 0.985 }}
target="_blank"
rel="noopener noreferrer"
href="https://docs.rxresu.me/getting-started"
>
<Badge variant="secondary" className="h-auto gap-1.5 px-3 py-0.5">
<SparkleIcon aria-hidden="true" className="size-3.5" weight="fill" />
<Trans>What's new in the latest version?</Trans>
</Badge>
</motion.a>
{/* Headline */}
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.7 }}
>
<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">
A free and open-source resume builder
</h1>
</Trans>
</motion.div>
{/* Description */}
<motion.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 }}
transition={{ duration: 0.45, delay: 0.82 }}
>
<Trans>
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
and sharing your resume.
</Trans>
</motion.p>
{/* CTA Buttons */}
<motion.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 }}
transition={{ duration: 0.45, delay: 0.95 }}
>
<Button
size="lg"
nativeButton={false}
className="group relative overflow-hidden px-4"
render={
<Link to="/dashboard">
<span className="relative z-10 flex items-center gap-2">
<Trans>Get Started</Trans>
<ArrowRightIcon
aria-hidden="true"
className="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</Link>
}
/>
<Button
size="lg"
variant="ghost"
className="gap-2 px-4"
nativeButton={false}
render={
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
<BookIcon aria-hidden="true" className="size-4" />
<Trans>Learn More</Trans>
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
</a>
}
/>
</motion.div>
</div>
{/* Scroll indicator - decorative */}
<motion.div
aria-hidden="true"
role="presentation"
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.25, duration: 0.7 }}
>
<motion.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>
</section>
);
}
@@ -0,0 +1,40 @@
import { Trans } from "@lingui/react/macro";
import { motion } from "motion/react";
import { TextMaskEffect } from "@/components/animation/text-mask";
export function Prefooter() {
return (
<section id="prefooter" className="relative overflow-hidden py-16 md:py-24">
{/* Background decoration */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute inset-s-1/4 top-0 size-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute inset-e-1/4 bottom-0 size-96 rounded-full bg-primary/5 blur-3xl" />
</div>
<div className="relative space-y-8">
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
<motion.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">
<Trans>By the community, for the community.</Trans>
</h2>
<p className="text-muted-foreground leading-relaxed">
<Trans>
Reactive Resume continues to grow thanks to its vibrant community. This project owes its progress to
numerous individuals who've dedicated their time and skills to make it better. We celebrate the coders
who've enhanced its features on GitHub, the linguists whose translations on Crowdin have made it
accessible to a broader audience, and the people who've donated to support its continued development.
</Trans>
</p>
</motion.div>
</div>
</section>
);
}
@@ -0,0 +1,105 @@
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 { CountUp } from "@/components/animation/count-up";
import { orpc } from "@/libs/orpc/client";
type Statistic = {
id: string;
label: string;
value: number;
icon: Icon;
};
const getStatistics = (userCount: number, resumeCount: number): Statistic[] => [
{
id: "users",
label: t`Users`,
value: userCount,
icon: UsersIcon,
},
{
id: "resumes",
label: t`Resumes`,
value: resumeCount,
icon: FileTextIcon,
},
];
type StatisticCardProps = {
statistic: Statistic;
index: number;
};
function StatisticCard({ statistic, index }: StatisticCardProps) {
const Icon = statistic.icon;
return (
<motion.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 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.1, ease: "easeOut" }}
>
{/* Background decoration */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<motion.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 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: index * 0.1 + 0.2 }}
>
<Icon size={180} weight="thin" />
</motion.div>
</div>
{/* Icon */}
<motion.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>
{/* Value */}
<CountUp
key={statistic.id}
separator=","
duration={0.8}
to={statistic.value}
className="font-bold text-5xl tracking-tight md:text-6xl"
/>
{/* Label */}
<p className="relative font-medium text-base text-muted-foreground tracking-tight">{statistic.label}</p>
</motion.div>
);
}
export function Statistics() {
const [userCountResult, resumeCountResult] = useQueries({
queries: [orpc.statistics.user.getCount.queryOptions(), orpc.statistics.resume.getCount.queryOptions()],
});
if (!userCountResult.data || !resumeCountResult.data) return null;
return (
<section id="statistics" aria-labelledby="stats-heading">
<h2 id="stats-heading" className="sr-only">
{t`Application Statistics`}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2">
{getStatistics(userCountResult.data, resumeCountResult.data).map((statistic, index) => (
<StatisticCard key={statistic.id} statistic={statistic} index={index} />
))}
</div>
</section>
);
}
@@ -0,0 +1,116 @@
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
import { Trans } from "@lingui/react/macro";
import { motion } from "motion/react";
import { useMemo } from "react";
import { templates } from "@/dialogs/resume/template/data";
type TemplateItemProps = {
metadata: TemplateMetadata;
};
function TemplateItem({ metadata }: TemplateItemProps) {
return (
<motion.div
className="group relative shrink-0 will-change-transform"
initial={{ scale: 1, zIndex: 10 }}
whileHover={{ scale: 1.06, zIndex: 20 }}
whileTap={{ scale: 0.99 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
>
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
{/* Subtle overlay on hover */}
<div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
{/* Template name on hover */}
<div className="absolute inset-x-0 bottom-0 translate-y-full p-4 transition-transform duration-300 group-hover:translate-y-0">
<p className="font-semibold text-white drop-shadow-lg">{metadata.name}</p>
</div>
{/* 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>
);
}
type MarqueeRowProps = {
templates: Array<[string, TemplateMetadata]>;
rowId: string;
direction: "left" | "right";
duration?: number;
};
function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowProps) {
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
return (
<motion.div
className="flex gap-x-4 will-change-transform sm:gap-x-6"
animate={{ x: animateX }}
transition={{
x: {
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
duration,
ease: "linear",
},
}}
>
{templates.map(([template, metadata], index) => (
<TemplateItem key={`${rowId}-${template}-${index}`} metadata={metadata} />
))}
</motion.div>
);
}
export function Templates() {
// Split templates into two rows and duplicate for seamless infinite scroll
const { row1, row2 } = useMemo(() => {
const entries = Object.entries(templates);
const half = Math.ceil(entries.length / 2);
const firstHalf = entries.slice(0, half);
const secondHalf = entries.slice(half);
// Duplicate each row for seamless scrolling
return {
row1: [...firstHalf, ...firstHalf],
row2: [...secondHalf, ...secondHalf],
};
}, []);
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<motion.div
className="space-y-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.35 }}
>
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
<Trans>Templates</Trans>
</h2>
<p className="max-w-2xl text-muted-foreground leading-relaxed">
<Trans>
Explore our diverse selection of templates, each designed to fit different styles, professions, and
personalities. Reactive Resume currently offers 12 templates, with more on the way.
</Trans>
</p>
</motion.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} />
{/* Second row - moves right to left (opposite direction) */}
<MarqueeRow templates={row2} rowId="row2" direction="right" duration={50} />
</div>
</div>
</section>
);
}
@@ -0,0 +1,144 @@
import { Trans } from "@lingui/react/macro";
import { QuotesIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useMemo } from "react";
const email = "hello@amruthpillai.com";
const testimonials: string[] = [
"Great site. Love the interactive interface. You can tell it's designed by someone who wants to use it.",
"Truly everything about the UX is so intuitive, fluid and lets you customize your CV how you want and so rapidly. I thank you so much for putting the work to release something like this.",
"I want to appreciate you for making your projects #openSource, most especially your Reactive Resume, which is the handiest truly-free resume maker I've come across. This is a big shoutout to you. Well done!",
"I'd like to appreciate the great work you've done with rxresu.me. The website's design, smooth functionality, and ease of use under the free plan are really impressive. It's clear that a lot of thought and effort has gone into building and maintaining such a useful platform.",
"I just wanted to reach you out and thank you personally for your wonderful project rxresu.me. It is very valuable, and the fact that it is open source, makes it all the more meaningful, since there are lots of people who struggle to make their CV look good. For my part, it saved me a lot of time and helped me shape my CV in a very efficient way.",
"I appreciate your effort in open-sourcing and making it free for everyone to use, it's a great effort. By using this platform, I got a job secured in the government sector of Oman, that too in a ministry. Thank you for providing this platform. Keep going, appreciate the effort. ❤️",
"Your CV generator just saved my day! Thank you so much, great work!",
"I want to express my heartfelt gratitude and admiration for your incredible work and remarkable skills. Your projects, especially the Resume Builder, have been immensely helpful to me, and I deeply appreciate the effort and creativity you've poured into them.",
"Hey! Thank you so much for making this fantastic tool! It helped me get a new job as a Research Software Engineer at Arizona State University.",
"Wow, what an impressive profile! You are very talented. I'm also a fellow SWE on the job hunt and I came across a linked to Reactive Resume on Reddit and gave it a shot. This could easily be a paid product. Very clean and useful.",
"Thank you for creating Reactive Resume. It is an amazing product, and I love the design and how it simplifies the resume-making experience. I've been trying to create a good resume for a decade to find my first job in tech, and your tool has been incredibly helpful.",
];
type TestimonialCardProps = {
testimonial: string;
};
function TestimonialCard({ testimonial }: TestimonialCardProps) {
return (
<motion.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)" }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
>
<QuotesIcon
weight="fill"
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>
);
}
type TestimonialColumnProps = {
columnId: string;
testimonials: string[];
};
function TestimonialColumn({ columnId, 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} />
))}
</div>
);
}
type MarqueeMasonryProps = {
columns: string[][];
direction: "left" | "right";
duration?: number;
};
function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryProps) {
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
const marqueeColumns = [...columns, ...columns];
return (
<motion.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} />
))}
</motion.div>
);
}
export function Testimonials() {
const columns = useMemo(() => {
const columns: string[][] = [];
for (let index = 0; index < testimonials.length; index += 2) {
columns.push(testimonials.slice(index, index + 2));
}
return columns;
}, []);
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"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
<Trans>Testimonials</Trans>
</h2>
<p className="max-w-4xl text-balance text-muted-foreground leading-relaxed">
<Trans>
A lot of people have written to me over the years to share their experiences with Reactive Resume and how it
has helped them, and I never get tired of reading them. If you have a story to share, let me know by sending
me an email at{" "}
<a
href={`mailto:${email}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground underline underline-offset-2 transition-colors hover:text-primary"
>
{email}
</a>
.
</Trans>
</p>
</motion.div>
<div className="relative">
{/* Left fade */}
<div className="pointer-events-none absolute inset-s-0 top-0 bottom-0 z-10 w-16 bg-linear-to-r from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
{/* Right fade */}
<div className="pointer-events-none absolute inset-e-0 top-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
<MarqueeMasonry columns={columns} direction="left" duration={60} />
</div>
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { createFileRoute } from "@tanstack/react-router";
import { DonationBanner } from "./-sections/donate";
import { FAQ } from "./-sections/faq";
import { Features } from "./-sections/features";
import { Footer } from "./-sections/footer";
import { Hero } from "./-sections/hero";
import { Prefooter } from "./-sections/prefooter";
import { Statistics } from "./-sections/statistics";
import { Templates } from "./-sections/templates";
import { Testimonials } from "./-sections/testimonials";
export const Route = createFileRoute("/_home/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<main id="main-content" className="relative">
<Hero />
<div className="container mx-auto px-4 sm:px-6 lg:px-12">
<div className="border-border border-x [&>section:first-child]:border-t-0 [&>section]:border-border [&>section]:border-t">
<Statistics />
<Features />
<Templates />
<Testimonials />
<DonationBanner />
<FAQ />
<Prefooter />
<Footer />
</div>
</div>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Trans } from "@lingui/react/macro";
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { Header } from "./-sections/header";
export const Route = createFileRoute("/_home")({
component: RouteComponent,
});
function RouteComponent() {
return (
<>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:inset-s-4 focus:top-4 focus:z-100 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:shadow-lg focus:ring-2 focus:ring-ring"
>
<Trans>Skip to main content</Trans>
</a>
<Header />
<Outlet />
</>
);
}