chore: lint using react-doctor, update translations, dynamic imports

This commit is contained in:
Amruth Pillai
2026-05-21 09:56:26 +02:00
parent 3596102c63
commit 39e88dd365
208 changed files with 5876 additions and 4778 deletions
+25 -23
View File
@@ -11,7 +11,7 @@ import { IconContext } from "@phosphor-icons/react";
import { HotkeysProvider } from "@tanstack/react-hotkeys";
import { QueryClientProvider } from "@tanstack/react-query";
import { createRootRouteWithContext, HeadContent, Outlet } from "@tanstack/react-router";
import { MotionConfig } from "motion/react";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
import { useEffect, useMemo } from "react";
import { DirectionProvider } from "@reactive-resume/ui/components/direction";
import { Toaster } from "@reactive-resume/ui/components/sonner";
@@ -115,30 +115,32 @@ function RootComponent() {
<QueryClientProvider client={queryClient}>
<MotionConfig reducedMotion="user">
<I18nProvider i18n={i18n}>
<IconContext.Provider value={iconContextValue}>
<ThemeProvider theme={theme}>
<HotkeysProvider>
<DirectionProvider>
<TooltipProvider>
<ConfirmDialogProvider>
<PromptDialogProvider>
<Outlet />
<LazyMotion features={domAnimation}>
<I18nProvider i18n={i18n}>
<IconContext.Provider value={iconContextValue}>
<ThemeProvider theme={theme}>
<HotkeysProvider>
<DirectionProvider>
<TooltipProvider>
<ConfirmDialogProvider>
<PromptDialogProvider>
<Outlet />
<DonationToast />
<DialogManager />
<CommandPalette />
<Toaster richColors position="bottom-right" />
<DonationToast />
<DialogManager />
<CommandPalette />
<Toaster richColors position="bottom-right" />
{import.meta.env.DEV && <BreakpointIndicator />}
</PromptDialogProvider>
</ConfirmDialogProvider>
</TooltipProvider>
</DirectionProvider>
</HotkeysProvider>
</ThemeProvider>
</IconContext.Provider>
</I18nProvider>
{import.meta.env.DEV && <BreakpointIndicator />}
</PromptDialogProvider>
</ConfirmDialogProvider>
</TooltipProvider>
</DirectionProvider>
</HotkeysProvider>
</ThemeProvider>
</IconContext.Provider>
</I18nProvider>
</LazyMotion>
</MotionConfig>
</QueryClientProvider>
</>
+23 -23
View File
@@ -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>
);
+7 -7
View File
@@ -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>
);
}
+17 -19
View File
@@ -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 */}
+382 -253
View File
@@ -30,7 +30,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
@@ -176,10 +176,13 @@ function fileToBase64(file: File): Promise<string> {
}
function textFromMessage(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
const textParts: string[] = [];
for (const part of message.parts) {
if (part.type === "text") textParts.push(part.text);
}
return textParts.join("\n");
}
function parseAgentSseStream(stream: ReadableStream<string>) {
@@ -218,7 +221,7 @@ function parseAgentSseStream(stream: ReadableStream<string>) {
function promptPreview(prompt: string) {
const words = prompt.split(/\s+/).filter(Boolean);
return `${words.slice(0, 7).join(" ")}${words.length > 7 ? "..." : ""}`;
return `${words.slice(0, 7).join(" ")}${words.length > 7 ? "" : ""}`;
}
function chunkPrompts(prompts: string[], columns: number) {
@@ -252,20 +255,24 @@ function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void
return (
<div className="relative mx-auto grid w-full max-w-4xl gap-3 overflow-hidden py-1 [mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]">
{promptRows.map((row, rowIndex) => {
const marqueePrompts = [...row, ...row, ...row];
const marqueePrompts = row.flatMap((prompt) => [
{ id: `${prompt}-primary`, prompt },
{ id: `${prompt}-repeat-a`, prompt },
{ id: `${prompt}-repeat-b`, prompt },
]);
const duration = 135 + rowIndex * 22;
const animate = rowIndex % 2 === 0 ? { x: ["0%", "-33.333%"] } : { x: ["-33.333%", "0%"] };
return (
<motion.div
key={`prompt-row-${rowIndex}`}
<m.div
key={`prompt-row-${row.join("|")}`}
className="flex w-max gap-3"
animate={animate}
transition={{ duration, ease: "linear", repeat: Number.POSITIVE_INFINITY }}
>
{marqueePrompts.map((prompt, index) => (
{marqueePrompts.map(({ id, prompt }) => (
<Button
key={`${prompt}-${index}`}
key={id}
type="button"
variant="outline"
className="h-8 shrink-0 rounded-full bg-background/70 px-3 font-normal text-muted-foreground hover:text-foreground"
@@ -274,13 +281,21 @@ function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void
{promptPreview(prompt)}
</Button>
))}
</motion.div>
</m.div>
);
})}
</div>
);
}
function getMessagePartKey(messageId: string, part: UIMessage["parts"][number]) {
if ("toolCallId" in part && typeof part.toolCallId === "string")
return `${messageId}-${part.type}-${part.toolCallId}`;
if (part.type === "text") return `${messageId}-text-${part.text}`;
if (part.type === "file") return `${messageId}-file-${part.url ?? part.filename}`;
return `${messageId}-${part.type}-${JSON.stringify(part)}`;
}
function AssistantMarkdown({ text }: { text: string }) {
return (
<ReactMarkdown
@@ -436,9 +451,9 @@ function ChatMessage({
: "w-full max-w-full py-1 text-foreground",
)}
>
{message.parts.map((part, index) => (
{message.parts.map((part) => (
<MessagePart
key={`${message.id}-${index}`}
key={getMessagePartKey(message.id, part)}
part={part}
isUser={isUser}
onAnswer={onAnswer}
@@ -621,18 +636,19 @@ function AgentChat({
setIsUploading(true);
try {
for (const file of Array.from(files)) {
const attachment = await client.agent.attachments.create({
threadId,
filename: file.name,
mediaType: file.type || "application/octet-stream",
data: await fileToBase64(file),
});
setPendingAttachments((current) => [
...current,
{ id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType },
]);
}
const attachments = await Promise.all(
Array.from(files).map(async (file) => {
const attachment = await client.agent.attachments.create({
threadId,
filename: file.name,
mediaType: file.type || "application/octet-stream",
data: await fileToBase64(file),
});
return { id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType };
}),
);
setPendingAttachments((current) => [...current, ...attachments]);
toast.success(t`Attachment uploaded.`);
} catch (error) {
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }));
@@ -669,245 +685,358 @@ function AgentChat({
toast.success(t`Conversation JSON copied.`);
};
const copyConversationText = () => {
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
toast.success(t`Conversation copied.`);
};
const answerToolCall = (toolCallId: string, answer: string) => {
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
};
const revertAction = (actionId: string) => {
const confirmation = window.confirm(
t`Restore the resume to before this patch? This will roll back this patch and any patches applied after it.`,
);
if (!confirmation) return;
revertMutation.mutate(
{ id: actionId },
{
onSuccess: (action) => {
if (action.status === "conflicted") {
toast.error(action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`);
} else if (action.status === "rolled_back" || action.status === "reverted") {
toast.success(t`Patch rolled back.`);
}
void refreshThread();
},
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
},
);
};
const retryLastMessage = () => {
clearError();
void regenerate();
};
return (
<section className="flex h-full min-h-0 flex-col bg-background">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div className="flex min-w-0 items-center gap-2">
{onToggleThreads ? (
<Button size="icon-sm" variant="ghost" onClick={onToggleThreads}>
<SidebarSimpleIcon />
<span className="sr-only">
<Trans>Toggle threads</Trans>
</span>
</Button>
) : null}
<AgentChatHeader
isArchived={isArchived}
isArchivePending={archiveMutation.isPending}
isDeletePending={deleteMutation.isPending}
onArchive={handleArchive}
onCopyConversation={copyConversationText}
onCopyConversationJson={copyConversationJson}
onDelete={() => void handleDelete()}
onToggleResume={onToggleResume}
onToggleThreads={onToggleThreads}
/>
<div className="min-w-0 truncate font-semibold">
<Trans>Chat</Trans>
<AgentChatReadOnlyBanner isReadOnly={isReadOnly} readOnlyReason={readOnlyReason} />
<AgentChatMessages
actionsById={actionsById}
error={error}
isReadOnly={isReadOnly}
isReverting={revertMutation.isPending}
isStreaming={isStreaming}
messages={messages}
onAnswer={answerToolCall}
onRevert={revertAction}
onRetry={retryLastMessage}
onStarterSelect={setInput}
/>
<AgentChatComposer
fileInputRef={fileInputRef}
input={input}
isReadOnly={isReadOnly}
isStreaming={isStreaming}
isUploading={isUploading}
pendingAttachments={pendingAttachments}
onInputChange={setInput}
onSend={send}
onStopRun={() => void stopRun()}
onUploadFiles={(files) => void uploadFiles(files)}
/>
</section>
);
}
function AgentChatReadOnlyBanner({
isReadOnly,
readOnlyReason,
}: {
isReadOnly: boolean;
readOnlyReason: "archived" | "missing" | null;
}) {
if (!isReadOnly) return null;
return (
<div className="border-amber-300 border-b bg-amber-50 px-4 py-2 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
{readOnlyReason === "archived" ? (
<Trans>This thread is archived. New messages cannot be sent.</Trans>
) : (
<Trans>This thread is read-only because the working resume or AI provider is unavailable.</Trans>
)}
</div>
);
}
function AgentChatMessages({
actionsById,
error,
isReadOnly,
isReverting,
isStreaming,
messages,
onAnswer,
onRevert,
onRetry,
onStarterSelect,
}: {
actionsById: Map<string, AgentAction>;
error: Error | undefined;
isReadOnly: boolean;
isReverting: boolean;
isStreaming: boolean;
messages: UIMessage[];
onAnswer: (toolCallId: string, answer: string) => void;
onRevert: (actionId: string) => void;
onRetry: () => void;
onStarterSelect: (prompt: string) => void;
}) {
return (
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<div className="grid gap-6 py-12 text-center">
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
<h2 className="font-semibold text-2xl">
<Trans>What do you want to do?</Trans>
</h2>
<StarterPromptMarquee onSelect={onStarterSelect} />
</div>
</div>
<div className="flex items-center gap-1">
{onToggleResume ? (
<Button size="icon-sm" variant="ghost" onClick={onToggleResume}>
<SquaresFourIcon />
<span className="sr-only">
<Trans>Toggle resume preview</Trans>
</span>
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon-sm" variant="ghost">
<DotsThreeVerticalIcon />
<span className="sr-only">
<Trans>Thread actions</Trans>
</span>
</Button>
}
/>
) : null}
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
toast.success(t`Conversation copied.`);
}}
>
<CopyIcon />
<Trans>Copy</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyConversationJson}>
<CopyIcon />
<Trans>Copy JSON</Trans>
</DropdownMenuItem>
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isReverting={isReverting}
actionsById={actionsById}
onAnswer={onAnswer}
onRevert={onRevert}
/>
))}
<DropdownMenuSeparator />
{isStreaming ? (
<div className="flex justify-start">
<div className="rounded-md bg-muted px-4 py-3 text-muted-foreground text-sm">
<Trans>Working</Trans>
</div>
</div>
) : null}
{!isArchived ? (
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
<ArchiveIcon />
<Trans>Archive</Trans>
</DropdownMenuItem>
) : null}
{error ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-300 bg-rose-50 p-3 text-rose-950 text-sm dark:bg-rose-950/20 dark:text-rose-200">
<span>{error.message}</span>
{!isReadOnly ? (
<Button size="sm" variant="outline" type="button" onClick={onRetry}>
<ArrowClockwiseIcon />
<Trans>Retry</Trans>
</Button>
) : null}
</div>
) : null}
</div>
</ScrollArea>
);
}
<DropdownMenuItem
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => void handleDelete()}
>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
function AgentChatHeader({
isArchived,
isArchivePending,
isDeletePending,
onArchive,
onCopyConversation,
onCopyConversationJson,
onDelete,
onToggleResume,
onToggleThreads,
}: {
isArchived: boolean;
isArchivePending: boolean;
isDeletePending: boolean;
onArchive: () => void;
onCopyConversation: () => void;
onCopyConversationJson: () => void;
onDelete: () => void;
onToggleResume?: () => void;
onToggleThreads?: () => void;
}) {
return (
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div className="flex min-w-0 items-center gap-2">
{onToggleThreads ? (
<Button size="icon-sm" variant="ghost" onClick={onToggleThreads}>
<SidebarSimpleIcon />
<span className="sr-only">
<Trans>Toggle threads</Trans>
</span>
</Button>
) : null}
<div className="min-w-0 truncate font-semibold">
<Trans>Chat</Trans>
</div>
</div>
<div className="flex items-center gap-1">
{onToggleResume ? (
<Button size="icon-sm" variant="ghost" onClick={onToggleResume}>
<SquaresFourIcon />
<span className="sr-only">
<Trans>Toggle resume preview</Trans>
</span>
</Button>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon-sm" variant="ghost">
<DotsThreeVerticalIcon />
<span className="sr-only">
<Trans>Thread actions</Trans>
</span>
</Button>
}
/>
{isReadOnly ? (
<div className="border-amber-300 border-b bg-amber-50 px-4 py-2 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
{readOnlyReason === "archived" ? (
<Trans>This thread is archived. New messages cannot be sent.</Trans>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onCopyConversation}>
<CopyIcon />
<Trans>Copy</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopyConversationJson}>
<CopyIcon />
<Trans>Copy JSON</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isArchived ? (
<DropdownMenuItem disabled={isArchivePending} onClick={onArchive}>
<ArchiveIcon />
<Trans>Archive</Trans>
</DropdownMenuItem>
) : null}
<DropdownMenuItem variant="destructive" disabled={isDeletePending} onClick={onDelete}>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
function AgentChatComposer({
fileInputRef,
input,
isReadOnly,
isStreaming,
isUploading,
pendingAttachments,
onInputChange,
onSend,
onStopRun,
onUploadFiles,
}: {
fileInputRef: React.RefObject<HTMLInputElement | null>;
input: string;
isReadOnly: boolean;
isStreaming: boolean;
isUploading: boolean;
pendingAttachments: Array<Pick<AgentAttachment, "filename" | "id" | "mediaType">>;
onInputChange: (value: string) => void;
onSend: () => void;
onStopRun: () => void;
onUploadFiles: (files: FileList | null) => void;
}) {
return (
<form
className="border-t p-3"
onSubmit={(event) => {
event.preventDefault();
onSend();
}}
>
<div className="mx-auto max-w-3xl space-y-2">
{pendingAttachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{pendingAttachments.map((attachment) => (
<Badge key={attachment.id} variant="secondary">
<FileIcon />
{attachment.filename}
</Badge>
))}
</div>
) : null}
<div className="flex items-end gap-1 rounded-md border bg-card p-1.5">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => onUploadFiles(event.target.files)}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t`Attach files`}
disabled={isReadOnly || isUploading}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? <ArrowClockwiseIcon className="animate-spin" /> : <PaperclipIcon />}
</Button>
<Textarea
value={input}
rows={1}
disabled={isReadOnly || isStreaming}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
onSend();
}}
placeholder={isReadOnly ? t`This thread is read-only` : t`Ask anything about this resume`}
className="max-h-40 min-h-9 resize-none border-0 bg-transparent p-2 leading-5 shadow-none focus-visible:ring-0"
/>
{isStreaming && !isReadOnly ? (
<Button type="button" size="icon" variant="outline" aria-label={t`Stop generation`} onClick={onStopRun}>
<StopIcon />
</Button>
) : (
<Trans>This thread is read-only because the working resume or AI provider is unavailable.</Trans>
<Button
type="submit"
size="icon"
aria-label={t`Send message`}
disabled={isReadOnly || isUploading || (!input.trim() && pendingAttachments.length === 0)}
>
<PaperPlaneRightIcon />
</Button>
)}
</div>
) : null}
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<div className="grid gap-6 py-12 text-center">
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
<h2 className="font-semibold text-2xl">
<Trans>What do you want to do?</Trans>
</h2>
<StarterPromptMarquee onSelect={setInput} />
</div>
) : null}
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
isReverting={revertMutation.isPending}
actionsById={actionsById}
onAnswer={(toolCallId, answer) => {
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
}}
onRevert={(actionId) => {
const confirmation = window.confirm(
t`Restore the resume to before this patch? This will roll back this patch and any patches applied after it.`,
);
if (!confirmation) return;
revertMutation.mutate(
{ id: actionId },
{
onSuccess: (action) => {
if (action.status === "conflicted") {
toast.error(
action.revertMessage ??
t`Cannot restore; the resume has changed since this edit was applied.`,
);
} else if (action.status === "rolled_back" || action.status === "reverted") {
toast.success(t`Patch rolled back.`);
}
void refreshThread();
},
onError: (error) =>
toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
},
);
}}
/>
))}
{isStreaming ? (
<div className="flex justify-start">
<div className="rounded-md bg-muted px-4 py-3 text-muted-foreground text-sm">
<Trans>Working...</Trans>
</div>
</div>
) : null}
{error ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-300 bg-rose-50 p-3 text-rose-950 text-sm dark:bg-rose-950/20 dark:text-rose-200">
<span>{error.message}</span>
{!isReadOnly ? (
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
clearError();
void regenerate();
}}
>
<ArrowClockwiseIcon />
<Trans>Retry</Trans>
</Button>
) : null}
</div>
) : null}
</div>
</ScrollArea>
<form
className="border-t p-3"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<div className="mx-auto max-w-3xl space-y-2">
{pendingAttachments.length > 0 ? (
<div className="flex flex-wrap gap-2">
{pendingAttachments.map((attachment) => (
<Badge key={attachment.id} variant="secondary">
<FileIcon />
{attachment.filename}
</Badge>
))}
</div>
) : null}
<div className="flex items-end gap-1 rounded-md border bg-card p-1.5">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => void uploadFiles(event.target.files)}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t`Attach files`}
disabled={isReadOnly || isUploading}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? <ArrowClockwiseIcon className="animate-spin" /> : <PaperclipIcon />}
</Button>
<Textarea
value={input}
rows={1}
disabled={isReadOnly || isStreaming}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
send();
}}
placeholder={isReadOnly ? t`This thread is read-only` : t`Ask anything about this resume`}
className="max-h-40 min-h-9 resize-none border-0 bg-transparent px-2 py-2 leading-5 shadow-none focus-visible:ring-0"
/>
{isStreaming && !isReadOnly ? (
<Button
type="button"
size="icon"
variant="outline"
aria-label={t`Stop generation`}
onClick={() => void stopRun()}
>
<StopIcon />
</Button>
) : (
<Button
type="submit"
size="icon"
aria-label={t`Send message`}
disabled={isReadOnly || isUploading || (!input.trim() && pendingAttachments.length === 0)}
>
<PaperPlaneRightIcon />
</Button>
)}
</div>
</div>
</form>
</section>
</div>
</form>
);
}
@@ -974,7 +1103,7 @@ function ResumePane({ resume }: { resume: AgentThreadDetail["resume"] }) {
if (!resume) return;
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
const toastId = toast.loading(t`Please wait while your PDF is being generated`);
setIsPrinting(true);
@@ -1117,7 +1246,7 @@ function RouteComponent() {
if (isLoading) {
return (
<div className="grid h-svh place-items-center bg-background text-muted-foreground">
<Trans>Loading agent workspace...</Trans>
<Trans>Loading agent workspace</Trans>
</div>
);
}
@@ -4,7 +4,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -44,17 +44,10 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderId, setAiProviderId] = useState<string | null>(null);
const [sourceResumeId, setSourceResumeId] = useState<string | null>(resumeId ?? null);
useEffect(() => {
if (aiProviderId || usableProviders.length === 0) return;
setAiProviderId(usableProviders[0]?.id ?? null);
}, [aiProviderId, usableProviders]);
useEffect(() => {
setSourceResumeId(resumeId ?? null);
}, [resumeId]);
const [aiProviderIdOverride, setAiProviderIdOverride] = useState<string | null | undefined>(undefined);
const [sourceResumeIdOverride, setSourceResumeIdOverride] = useState<string | null | undefined>(undefined);
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
const sourceResumeId = sourceResumeIdOverride ?? resumeId ?? null;
const providerOptions = usableProviders.map((provider) => ({
value: provider.id,
@@ -119,8 +112,8 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
value={aiProviderId}
options={providerOptions}
disabled={isLoadingProviders || providerOptions.length === 0}
placeholder={isLoadingProviders ? t`Loading providers...` : t`Select a tested provider`}
onValueChange={setAiProviderId}
placeholder={isLoadingProviders ? t`Loading providers` : t`Select a tested provider`}
onValueChange={setAiProviderIdOverride}
/>
{providerOptions.length === 0 && !isLoadingProviders ? (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
@@ -157,8 +150,8 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
showClear={false}
options={resumeOptions}
disabled={isLoadingResumes}
placeholder={isLoadingResumes ? t`Loading resumes...` : t`Choose a resume`}
onValueChange={(value) => setSourceResumeId(value && value !== "__scratch__" ? value : null)}
placeholder={isLoadingResumes ? t`Loading resumes` : t`Choose a resume`}
onValueChange={(value) => setSourceResumeIdOverride(value && value !== "__scratch__" ? value : null)}
/>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
<Badge variant="secondary" className="h-7 gap-1.5 rounded-md px-2">
@@ -12,6 +12,7 @@ import {
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import {
@@ -28,28 +29,26 @@ import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
function formatRelativeTime(value: Date | string, locale: string) {
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
const divisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
if (absMs < 60_000) return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(0, "second");
if (absMs < 60_000) return formatter.format(0, "second");
const division = divisions.find((candidate) => absMs >= candidate.amount);
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
Math.round(diffMs / division.amount),
division.unit,
);
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
@@ -130,6 +129,10 @@ function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary;
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
const { i18n } = useLingui();
const relativeTimeFormatter = useMemo(
() => Reflect.construct(Intl.RelativeTimeFormat, [i18n.locale, { numeric: "auto" }]) as Intl.RelativeTimeFormat,
[i18n.locale],
);
const isActive = thread.id === activeThreadId;
const isArchived = thread.status === "archived";
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
@@ -149,7 +152,7 @@ function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; act
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, i18n.locale)}
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
@@ -197,7 +200,7 @@ export function AgentThreadSidebar({
{isLoading ? (
<div className="px-3 py-2 text-muted-foreground text-sm">
<Trans>Loading threads...</Trans>
<Trans>Loading threads</Trans>
</div>
) : null}
@@ -15,7 +15,7 @@ import {
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { m } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useControls } from "react-zoom-pan-pinch";
import { toast } from "sonner";
@@ -96,7 +96,7 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
return (
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
<motion.div
<m.div
initial={{ opacity: 0, y: -18 }}
animate={{ opacity: 0.6, y: 0 }}
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
@@ -130,7 +130,7 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
icon={isPrinting ? CircleNotchIcon : FilePdfIcon}
iconClassName={cn(isPrinting && "animate-spin")}
/>
</motion.div>
</m.div>
</div>
);
}
@@ -149,7 +149,7 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName, active
<Tooltip>
<TooltipTrigger
render={
<motion.div
<m.div
className="will-change-transform"
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
whileTap={disabled ? undefined : { scale: 0.97 }}
@@ -165,7 +165,7 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName, active
>
<Icon className={cn("size-4", iconClassName)} />
</Button>
</motion.div>
</m.div>
}
/>
@@ -2,8 +2,8 @@ import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, TrashSimpleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { pictureSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
@@ -32,6 +32,306 @@ export function PictureSectionBuilder() {
);
}
function PicturePreviewControls({
fileInputRef,
form,
normalizedPictureUrl,
picture,
pictureSrc,
onAutoSave,
onDeletePicture,
onSelectPicture,
onUploadPicture,
}: {
fileInputRef: React.RefObject<HTMLInputElement | null>;
form: PictureSettingsForm;
normalizedPictureUrl: string;
picture: PictureValues;
pictureSrc: string;
onAutoSave: () => void;
onDeletePicture: () => void;
onSelectPicture: () => void;
onUploadPicture: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<div className="flex items-center gap-x-4">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
<button
type="button"
onClick={picture.url ? onDeletePicture : onSelectPicture}
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
>
{(pictureSrc || normalizedPictureUrl) && (
<img
alt=""
src={pictureSrc || normalizedPictureUrl}
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
/>
)}
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
</div>
</button>
<form.Field name="url">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>URL</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
onAutoSave();
}}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={() => {
form.setFieldValue("hidden", !picture.hidden);
onAutoSave();
}}
>
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
</Button>
</div>
</FormItem>
)}
</form.Field>
</div>
);
}
function PictureGeometryFields({ form, onAutoSave }: { form: PictureSettingsForm; onAutoSave: () => void }) {
return (
<>
<form.Field name="size">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Size</Trans>
</FormLabel>
<InputGroup>
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={32}
max={512}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
onAutoSave();
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="rotation">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Rotation</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={360}
step={5}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
onAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>°</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
<form.Field name="aspectRatio">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Aspect Ratio</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
type="number"
min={0.5}
max={2.5}
step={0.1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
onAutoSave();
}}
/>
}
/>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to square",
message: "Square",
})}
onClick={() => {
field.handleChange(1);
onAutoSave();
}}
>
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to landscape orientation",
message: "Landscape",
})}
onClick={() => {
field.handleChange(1.5);
onAutoSave();
}}
>
<div className="aspect-1.5/1 min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to portrait orientation",
message: "Portrait",
})}
onClick={() => {
field.handleChange(0.5);
onAutoSave();
}}
>
<div className="aspect-1/1.5 min-h-3 min-w-3 border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
<form.Field name="borderRadius">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Border Radius</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={100}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = Number(e.target.value);
field.handleChange(value);
onAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
</InputGroup>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title="0pt"
onClick={() => {
field.handleChange(0);
onAutoSave();
}}
>
<div className="size-3 rounded-none border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="10pt"
onClick={() => {
field.handleChange(10);
onAutoSave();
}}
>
<div className="size-3 rounded-[10%] border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="100pt"
onClick={() => {
field.handleChange(100);
onAutoSave();
}}
>
<div className="size-3 rounded-full border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
</>
);
}
type PictureValues = z.infer<typeof pictureSchema>;
function normalizePictureUrl(url: string, origin: string): string {
@@ -48,6 +348,32 @@ function normalizePictureUrl(url: string, origin: string): string {
}
}
async function createPicturePreviewUrl(url: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
function usePictureSettingsForm(picture: PictureValues, persist: (data: PictureValues) => void) {
const form = useAppForm({
defaultValues: picture,
validators: { onChange: pictureSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
useSyncFormValues(form, picture);
return form;
}
type PictureSettingsForm = ReturnType<typeof usePictureSettingsForm>;
function PictureSectionForm() {
const fileInputRef = useRef<HTMLInputElement>(null);
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
@@ -55,7 +381,13 @@ function PictureSectionForm() {
const resume = useCurrentResume();
const picture = resume.data.picture;
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
const [pictureSrc, setPictureSrc] = useState("");
const picturePreviewQuery = useQuery({
queryKey: ["resume-picture-preview", normalizedPictureUrl],
queryFn: ({ signal }) => createPicturePreviewUrl(normalizedPictureUrl, signal),
enabled: Boolean(normalizedPictureUrl),
gcTime: 0,
});
const pictureSrc = picturePreviewQuery.data ?? normalizedPictureUrl;
const updateResumeData = useUpdateResumeData();
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
@@ -67,14 +399,7 @@ function PictureSectionForm() {
});
};
const form = useAppForm({
defaultValues: picture,
validators: { onChange: pictureSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
useSyncFormValues(form, picture);
const form = usePictureSettingsForm(picture, persist);
const handleAutoSave = () => {
persist(form.state.values);
@@ -106,7 +431,7 @@ function PictureSectionForm() {
const file = e.target.files?.[0];
if (!file) return;
const toastId = toast.loading(t`Uploading picture...`);
const toastId = toast.loading(t`Uploading picture`);
uploadFile(file, {
onSuccess: ({ url }) => {
@@ -131,33 +456,12 @@ function PictureSectionForm() {
};
useEffect(() => {
if (!normalizedPictureUrl) {
setPictureSrc("");
return;
}
const controller = new AbortController();
let objectUrl = "";
void fetch(normalizedPictureUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to fetch image: ${response.status}`);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setPictureSrc(objectUrl);
})
.catch(() => {
if (controller.signal.aborted) return;
setPictureSrc(normalizedPictureUrl);
});
const objectUrl = picturePreviewQuery.data;
return () => {
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [normalizedPictureUrl]);
}, [picturePreviewQuery.data]);
return (
<form
@@ -168,276 +472,20 @@ function PictureSectionForm() {
void form.handleSubmit();
}}
>
<div className="flex items-center gap-x-4">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
<button
type="button"
onClick={picture.url ? onDeletePicture : onSelectPicture}
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
>
{(pictureSrc || normalizedPictureUrl) && (
<img
alt=""
src={pictureSrc || normalizedPictureUrl}
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
/>
)}
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
</div>
</button>
<form.Field name="url">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>URL</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
handleAutoSave();
}}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={() => {
form.setFieldValue("hidden", !picture.hidden);
handleAutoSave();
}}
>
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
</Button>
</div>
</FormItem>
)}
</form.Field>
</div>
<PicturePreviewControls
fileInputRef={fileInputRef}
form={form}
normalizedPictureUrl={normalizedPictureUrl}
picture={picture}
pictureSrc={pictureSrc}
onAutoSave={handleAutoSave}
onDeletePicture={onDeletePicture}
onSelectPicture={onSelectPicture}
onUploadPicture={onUploadPicture}
/>
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
<form.Field name="size">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Size</Trans>
</FormLabel>
<InputGroup>
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={32}
max={512}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="rotation">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Rotation</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={360}
step={5}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>°</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
<form.Field name="aspectRatio">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Aspect Ratio</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
type="number"
min={0.5}
max={2.5}
step={0.1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to square",
message: "Square",
})}
onClick={() => {
field.handleChange(1);
handleAutoSave();
}}
>
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to landscape orientation",
message: "Landscape",
})}
onClick={() => {
field.handleChange(1.5);
handleAutoSave();
}}
>
<div className="aspect-1.5/1 min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to portrait orientation",
message: "Portrait",
})}
onClick={() => {
field.handleChange(0.5);
handleAutoSave();
}}
>
<div className="aspect-1/1.5 min-h-3 min-w-3 border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
<form.Field name="borderRadius">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Border Radius</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={100}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = Number(e.target.value);
field.handleChange(value);
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
</InputGroup>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title="0pt"
onClick={() => {
field.handleChange(0);
handleAutoSave();
}}
>
<div className="size-3 rounded-none border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="10pt"
onClick={() => {
field.handleChange(10);
handleAutoSave();
}}
>
<div className="size-3 rounded-[10%] border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="100pt"
onClick={() => {
field.handleChange(100);
handleAutoSave();
}}
>
<div className="size-3 rounded-full border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
<div className="flex items-end gap-x-3">
<form.Field name="borderColor">
@@ -51,7 +51,7 @@ export function SectionBase({ type, className, ...props }: Props) {
<div className="flex flex-1 items-center gap-x-4">
{getSectionIcon(type)}
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">
{("title" in section && section.title) || getSectionTitle(type)}
</h2>
</div>
@@ -1,7 +1,7 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
@@ -231,15 +231,15 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
<AnimatePresence>
{active && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
<m.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
>
<div className="size-4 rounded-md bg-foreground" />
</motion.div>
</m.div>
)}
</AnimatePresence>
</button>
@@ -1,5 +1,5 @@
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import type { CSSProperties, HTMLAttributes } from "react";
import type { CSSProperties, HTMLAttributes, Ref } from "react";
import {
closestCorners,
DndContext,
@@ -14,7 +14,7 @@ import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { forwardRef, useCallback, useId, useState } from "react";
import { useCallback, useId, useState } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Switch } from "@reactive-resume/ui/components/switch";
@@ -95,8 +95,10 @@ export function LayoutPages() {
// Search through all pages
for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
const page = layout.pages[pageIndex];
if (page.main.includes(id)) return { pageIndex, columnId: "main" };
if (page.sidebar.includes(id)) return { pageIndex, columnId: "sidebar" };
const mainSections = new Set(page.main);
const sidebarSections = new Set(page.sidebar);
if (mainSections.has(id)) return { pageIndex, columnId: "main" };
if (sidebarSections.has(id)) return { pageIndex, columnId: "sidebar" };
}
return null;
@@ -395,35 +397,32 @@ function SortableLayoutItem({ id }: SortableLayoutItemProps) {
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
id: string;
ref?: Ref<HTMLDivElement>;
isDragging?: boolean;
isOverlay?: boolean;
};
const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
({ id, isDragging, isOverlay, className, style, ...rest }, ref) => {
const resume = useCurrentResume();
const title = resume ? resolveLayoutSectionTitle(resume.data, id) : id;
function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, ...rest }: LayoutItemContentProps) {
const resume = useCurrentResume();
const title = resume ? resolveLayoutSectionTitle(resume.data, id) : id;
return (
<div
ref={ref}
style={style}
data-overlay={isOverlay ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
className,
)}
{...rest}
>
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
<span className="truncate">{title}</span>
</div>
);
},
);
LayoutItemContent.displayName = "LayoutItemContent";
return (
<div
ref={ref}
style={style}
data-overlay={isOverlay ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
className,
)}
{...rest}
>
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
<span className="truncate">{title}</span>
</div>
);
}
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -90,7 +90,9 @@ export function ResumeAnalysisSectionBuilder() {
const analysis = analysisQuery.data;
const score = analysis?.overallScore ?? null;
const analyzeLabel = isPending ? t`Analyzing...` : t`Analyze Resume`;
const updatedAt = analysis?.updatedAt ?? null;
const [updatedAtLabel, setUpdatedAtLabel] = useState<string | null>(null);
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
if (score == null) return "bg-muted";
@@ -99,6 +101,10 @@ export function ResumeAnalysisSectionBuilder() {
return "bg-rose-600";
}, [score]);
useEffect(() => {
setUpdatedAtLabel(updatedAt ? new Date(updatedAt).toLocaleString() : null);
}, [updatedAt]);
const onAnalyze = () => {
if (!resume) return;
@@ -153,11 +159,11 @@ export function ResumeAnalysisSectionBuilder() {
);
})}
</div>
{analysis?.updatedAt && (
{updatedAtLabel ? (
<p className="text-muted-foreground text-xs leading-none">
<Trans>Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}</Trans>
<Trans>Last analyzed on {updatedAtLabel}</Trans>
</p>
)}
) : null}
</div>
</div>
</div>
@@ -67,7 +67,7 @@ type StatisticsItemProps = {
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
return (
<div>
<h4 className="mb-1 font-bold font-mono text-4xl">{value}</h4>
<h4 className="mb-1 font-mono font-semibold text-4xl">{value}</h4>
<p className="font-medium text-muted-foreground leading-none">{label}</p>
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
</div>
@@ -43,7 +43,7 @@ function TemplateSectionForm() {
</div>
</Button>
<div className="flex flex-1 flex-col space-y-4 @md:pt-1 @md:pb-3">
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
<div className="space-y-1">
<h3 className="font-semibold text-2xl capitalize tracking-tight">{metadata.name}</h3>
<p className="text-muted-foreground text-sm">{i18n.t(metadata.description)}</p>
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
@@ -66,13 +67,7 @@ function TypographySectionForm() {
void form.handleSubmit();
}}
>
<div className="col-span-full flex items-center gap-x-2">
<Separator className="basis-[16px]" />
<div className="shrink-0 font-medium text-base leading-none">
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
</div>
<Separator className="flex-1" />
</div>
<TypographyFieldGroup label={<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>} />
<form.Field name="body.fontFamily">
{(field) => (
@@ -198,13 +193,7 @@ function TypographySectionForm() {
)}
</form.Field>
<div className="col-span-full flex items-center gap-x-2">
<Separator className="basis-[16px]" />
<div className="shrink-0 font-medium text-base leading-none">
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
</div>
<Separator className="flex-1" />
</div>
<TypographyFieldGroup label={<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>} />
<form.Field name="heading.fontFamily">
{(field) => (
@@ -332,3 +321,13 @@ function TypographySectionForm() {
</form>
);
}
function TypographyFieldGroup({ label }: { label: ReactNode }) {
return (
<div className="col-span-full flex items-center gap-x-2">
<Separator className="basis-[16px]" />
<div className="shrink-0 font-medium text-base leading-none">{label}</div>
<Separator className="flex-1" />
</div>
);
}
@@ -34,7 +34,7 @@ export function SectionBase({ type, className, ...props }: Props) {
<div className="flex flex-1 items-center gap-x-4">
{getSectionIcon(type)}
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">{getSectionTitle(type)}</h2>
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{getSectionTitle(type)}</h2>
</div>
</div>
@@ -87,17 +87,9 @@ export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (build
const isMobile = useIsMobile();
const { width } = useWindowSize();
const maxSidebarSize = useMemo((): string | number => {
if (!width) return 0;
return isMobile ? "95%" : "45%";
}, [width, isMobile]);
const collapsedSidebarSize = useMemo((): number => {
if (!width) return 0;
return isMobile ? 0 : 48;
}, [width, isMobile]);
const expandSize = useMemo(() => (isMobile ? "95%" : "30%"), [isMobile]);
const maxSidebarSize: string | number = !width ? 0 : isMobile ? "95%" : "45%";
const collapsedSidebarSize = !width ? 0 : isMobile ? 0 : 48;
const expandSize = isMobile ? "95%" : "30%";
const isCollapsed = useCallback((side: "left" | "right") => {
const sidebar =
@@ -13,7 +13,7 @@ import {
WarningIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import {
@@ -185,7 +185,7 @@ export function DashboardSidebar() {
<AnimatePresence>
{state === "expanded" && (
<motion.div
<m.div
key="copyright"
className="will-change-[transform,opacity]"
initial={{ y: 12, opacity: 0 }}
@@ -194,7 +194,7 @@ export function DashboardSidebar() {
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Copyright className="wrap-break-word shrink-0 whitespace-normal p-2" />
</motion.div>
</m.div>
)}
</AnimatePresence>
</SidebarFooter>
@@ -22,7 +22,7 @@ export function BaseCard({ title, description, tags, className, children, ...pro
>
{children}
<div className="absolute inset-x-0 bottom-0 flex w-full flex-col justify-end space-y-0.5 bg-background/40 px-4 py-3 backdrop-blur-xs">
<div className="absolute inset-x-0 bottom-0 flex w-full flex-col justify-end gap-y-0.5 bg-background/40 px-4 py-3 backdrop-blur-xs">
<h3 className="truncate font-medium tracking-tight">{title}</h3>
<p className="truncate text-xs opacity-80">{description}</p>
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import { useMemo } from "react";
import { ResumeContextMenu } from "../menus/context-menu";
import { BaseCard } from "./base-card";
@@ -23,7 +23,7 @@ export function ResumeCard({ resume }: ResumeCardProps) {
return (
<ResumeContextMenu resume={resume}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
<motion.div
<m.div
className="will-change-transform"
whileHover={{ y: -2, scale: 1.005 }}
whileTap={{ scale: 0.998 }}
@@ -34,7 +34,7 @@ export function ResumeCard({ resume }: ResumeCardProps) {
<ResumeLockOverlay isLocked={resume.isLocked} />
</BaseCard>
</motion.div>
</m.div>
</Link>
</ResumeContextMenu>
);
@@ -44,7 +44,7 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
return (
<AnimatePresence>
{isLocked && (
<motion.div
<m.div
key="resume-lock-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
@@ -55,7 +55,7 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
<div className="flex items-center justify-center rounded-full bg-popover p-6">
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
</div>
</motion.div>
</m.div>
)}
</AnimatePresence>
);
@@ -3,7 +3,7 @@ import type { RouterOutput } from "@/libs/orpc/client";
import { FileTextIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useInView } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
@@ -15,71 +15,52 @@ type ResumeListItem = RouterOutput["resume"]["list"][number];
type ThumbnailState = { status: "error" | "idle" | "loading" } | { status: "ready"; url: string };
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined) {
const [thumbnail, setThumbnail] = useState<ThumbnailState>({ status: "idle" });
const currentUrlRef = useRef<string | null>(null);
const throwIfAborted = (signal: AbortSignal) => {
if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
};
const revokeCurrentThumbnail = useCallback(() => {
if (!currentUrlRef.current) return;
URL.revokeObjectURL(currentUrlRef.current);
currentUrlRef.current = null;
}, []);
const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) => {
const pdf = await createResumePdfBlob(data);
throwIfAborted(signal);
const url = await createPdfFirstPageImageUrl(pdf);
if (signal.aborted) {
URL.revokeObjectURL(url);
throwIfAborted(signal);
}
return url;
};
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
const thumbnailQuery = useQuery({
queryKey: ["resume-thumbnail", cacheKey],
queryFn: ({ signal }) => {
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
return createResumeThumbnailUrl(data, signal);
},
enabled: Boolean(data && cacheKey),
gcTime: 0,
});
useEffect(() => {
return () => {
revokeCurrentThumbnail();
};
}, [revokeCurrentThumbnail]);
if (thumbnailQuery.error) console.error("Failed to generate resume thumbnail", thumbnailQuery.error);
}, [thumbnailQuery.error]);
useEffect(() => {
if (!data || !cacheKey) {
revokeCurrentThumbnail();
setThumbnail({ status: "idle" });
return;
}
let isCancelled = false;
let nextUrl: string | null = null;
setThumbnail({ status: "loading" });
const generateThumbnail = async () => {
try {
const pdf = await createResumePdfBlob(data);
if (isCancelled) return;
nextUrl = await createPdfFirstPageImageUrl(pdf);
if (isCancelled) {
URL.revokeObjectURL(nextUrl);
nextUrl = null;
return;
}
revokeCurrentThumbnail();
currentUrlRef.current = nextUrl;
setThumbnail({ status: "ready", url: nextUrl });
nextUrl = null;
} catch (error) {
if (isCancelled) return;
console.error("Failed to generate resume thumbnail", error);
revokeCurrentThumbnail();
setThumbnail({ status: "error" });
}
};
void generateThumbnail();
const url = thumbnailQuery.data;
return () => {
isCancelled = true;
if (nextUrl) {
URL.revokeObjectURL(nextUrl);
}
if (url) URL.revokeObjectURL(url);
};
}, [data, cacheKey, revokeCurrentThumbnail]);
}, [thumbnailQuery.data]);
return thumbnail;
if (!data || !cacheKey) return { status: "idle" };
if (thumbnailQuery.isError) return { status: "error" };
if (thumbnailQuery.data) return { status: "ready", url: thumbnailQuery.data };
return { status: "loading" };
}
export function ResumeThumbnail({ isLocked, resume }: { isLocked: boolean; resume: ResumeListItem }) {
@@ -1,5 +1,5 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import { CreateResumeCard } from "./cards/create-card";
import { ImportResumeCard } from "./cards/import-card";
import { ResumeCard } from "./cards/resume-card";
@@ -13,7 +13,7 @@ type Props = {
export function GridView({ resumes }: Props) {
return (
<div className="grid 3xl:grid-cols-6 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<motion.div
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
@@ -21,9 +21,9 @@ export function GridView({ resumes }: Props) {
className="will-change-[transform,opacity]"
>
<CreateResumeCard />
</motion.div>
</m.div>
<motion.div
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
@@ -31,11 +31,11 @@ export function GridView({ resumes }: Props) {
className="will-change-[transform,opacity]"
>
<ImportResumeCard />
</motion.div>
</m.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
<motion.div
<m.div
layout
key={resume.id}
initial={{ opacity: 0, y: -20 }}
@@ -49,7 +49,7 @@ export function GridView({ resumes }: Props) {
className="will-change-[transform,opacity]"
>
<ResumeCard resume={resume} />
</motion.div>
</m.div>
))}
</AnimatePresence>
</div>
@@ -3,7 +3,7 @@ import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { DotsThreeIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence, m } from "motion/react";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
@@ -28,7 +28,7 @@ export function ListView({ resumes }: Props) {
return (
<div className="flex flex-col gap-y-1">
<motion.div
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
@@ -50,9 +50,9 @@ export function ListView({ resumes }: Props) {
<Trans>Start building your resume from scratch</Trans>
</p>
</Button>
</motion.div>
</m.div>
<motion.div
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
@@ -75,11 +75,11 @@ export function ListView({ resumes }: Props) {
<Trans>Continue where you left off</Trans>
</p>
</Button>
</motion.div>
</m.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
<motion.div
<m.div
layout
key={resume.id}
className="will-change-[transform,opacity]"
@@ -89,7 +89,7 @@ export function ListView({ resumes }: Props) {
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
>
<ResumeListItem resume={resume} />
</motion.div>
</m.div>
))}
</AnimatePresence>
</div>
+11 -4
View File
@@ -1,11 +1,16 @@
import { PDFViewer } from "@react-pdf/renderer";
import { createFileRoute } from "@tanstack/react-router";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateSchema } from "@reactive-resume/schema/templates";
import { useLocalizedResumeDocument } from "@/features/resume/export/pdf-document";
import { createNoindexFollowMeta } from "@/libs/seo";
const PDFViewer = lazy(async () => {
const { PDFViewer } = await import("@react-pdf/renderer");
return { default: PDFViewer };
});
export const Route = createFileRoute("/templates/$")({
component: TemplatePdfRoute,
errorComponent: () => <div>Template not found</div>,
@@ -25,8 +30,10 @@ function TemplatePdfRoute() {
if (!isClient || !resumeDocument) return null;
return (
<PDFViewer showToolbar={false} style={{ height: "100svh", width: "100svw", border: "none" }}>
{resumeDocument}
</PDFViewer>
<Suspense fallback={null}>
<PDFViewer showToolbar={false} style={{ height: "100svh", width: "100svw", border: "none" }}>
{resumeDocument}
</PDFViewer>
</Suspense>
);
}