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