mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 02:14:50 +10:00
initial commit of v5
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl } from "@/utils/file";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context, params: { username, slug } }) => {
|
||||
const resume = await context.queryClient.ensureQueryData(
|
||||
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
|
||||
);
|
||||
|
||||
return { resume };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: [{ title: loaderData ? `${loaderData.resume.name} - Reactive Resume` : "Reactive Resume" }],
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "NEED_PASSWORD") {
|
||||
const data = error.data as { username?: string; slug?: string } | undefined;
|
||||
const username = data?.username;
|
||||
const slug = data?.slug;
|
||||
|
||||
if (username && slug) {
|
||||
throw redirect({
|
||||
to: "/auth/resume-password",
|
||||
search: { redirect: `/${username}/${slug}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw notFound();
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { username, slug } = Route.useParams();
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initialize = useResumeStore((state) => state.initialize);
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
const { mutateAsync: printResumeAsPDF, isPending: isPrinting } = useMutation(
|
||||
orpc.printer.printResumeAsPDF.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resume) return;
|
||||
initialize(resume);
|
||||
return () => initialize(null);
|
||||
}, [resume, initialize]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const { url } = await printResumeAsPDF({ id: resume.id });
|
||||
downloadFromUrl(url, `resume-${resume.name}.pdf`);
|
||||
}, [resume, printResumeAsPDF]);
|
||||
|
||||
if (!isReady) return <LoadingScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("mx-auto max-w-[210mm]", "print:m-0 print:block print:max-w-full print:px-0", "md:my-4 md:px-4")}
|
||||
>
|
||||
<ResumePreview pageClassName="print:w-full! w-full max-w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
className="fixed right-4 bottom-4 z-50 hidden rounded-full px-4 md:inline-flex print:hidden"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{isPrinting ? <Spinner /> : <DownloadSimpleIcon />}
|
||||
{isPrinting ? <Trans>Downloading...</Trans> : <Trans>Download</Trans>}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { createRootRouteWithContext, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
|
||||
import { ThemeProvider } from "@/components/theme/provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { DialogManager } from "@/dialogs/manager";
|
||||
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
|
||||
import { PromptDialogProvider } from "@/hooks/use-prompt";
|
||||
import { getSession } from "@/integrations/auth/functions";
|
||||
import type { AuthSession } from "@/integrations/auth/types";
|
||||
import type { orpc } from "@/integrations/orpc/client";
|
||||
import { getLocale, isRTL, type Locale, loadLocale } from "@/utils/locale";
|
||||
import { getTheme, type Theme } from "@/utils/theme";
|
||||
import appCss from "../styles/globals.css?url";
|
||||
|
||||
type RouterContext = {
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
orpc: typeof orpc;
|
||||
queryClient: QueryClient;
|
||||
session: AuthSession | null;
|
||||
};
|
||||
|
||||
const appName = "Reactive Resume";
|
||||
const tagline = "A free and open-source resume builder";
|
||||
const title = `${appName} — ${tagline}`;
|
||||
const description =
|
||||
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
|
||||
|
||||
await loadLocale(await getLocale());
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
shellComponent: RootDocument,
|
||||
head: () => {
|
||||
const appUrl = process.env.APP_URL ?? "https://rxresu.me/";
|
||||
|
||||
return {
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
// Icons
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml", sizes: "any" },
|
||||
{ rel: "icon", href: "/favicon.ico", type: "image/x-icon", sizes: "48x48" },
|
||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon-180x180.png", type: "image/png", sizes: "180x180" },
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ charSet: "UTF-8" },
|
||||
{ meta: "description", content: description },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
// Twitter Tags
|
||||
{ property: "twitter:image", content: `${appUrl}/opengraph/banner.jpg` },
|
||||
{ property: "twitter:card", content: "summary_large_image" },
|
||||
{ property: "twitter:title", content: title },
|
||||
{ property: "twitter:description", content: description },
|
||||
// OpenGraph Tags
|
||||
{ property: "og:image", content: `${appUrl}/opengraph/banner.jpg` },
|
||||
{ property: "og:site_name", content: appName },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "og:url", content: appUrl },
|
||||
],
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const [theme, locale, session] = await Promise.all([getTheme(), getLocale(), getSession()]);
|
||||
|
||||
return { theme, locale, session };
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function RootDocument({ children }: Props) {
|
||||
const { theme, locale } = Route.useRouteContext();
|
||||
const dir = isRTL(locale) ? "rtl" : "ltr";
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning dir={dir} lang={locale} className={theme}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<I18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={{ size: 16, weight: "regular" }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ConfirmDialogProvider>
|
||||
<PromptDialogProvider>
|
||||
{children}
|
||||
|
||||
<DialogManager />
|
||||
<CommandPalette />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
|
||||
{import.meta.env.DEV && <BreakpointIndicator />}
|
||||
</PromptDialogProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</ThemeProvider>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
</MotionConfig>
|
||||
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
GithubLogoIcon,
|
||||
HeartIcon,
|
||||
type IconProps,
|
||||
RocketIcon,
|
||||
SparkleIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type FloatingIconProps = {
|
||||
icon: React.ElementType;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) => (
|
||||
<motion.div
|
||||
className={cn("absolute text-primary/20", className)}
|
||||
animate={{
|
||||
y: [0, -12, 0],
|
||||
rotate: [0, 5, -5, 0],
|
||||
scale: [1, 1.1, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
delay,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<Icon size={32} weight="duotone" />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const PulsingHeart = () => (
|
||||
<motion.div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.8],
|
||||
opacity: [0.6, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
type SparkleEffectProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SparkleEffect = ({ className }: SparkleEffectProps) => (
|
||||
<motion.div
|
||||
className={cn("absolute", className)}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
rotate: [0, 180],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<SparkleIcon size={16} weight="fill" className="text-amber-400" />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
type FeatureCardProps = {
|
||||
icon: React.ElementType<IconProps>;
|
||||
title: string;
|
||||
description: string;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps) => (
|
||||
<motion.div
|
||||
className="group relative flex flex-col items-center gap-3 rounded-2xl border border-border/50 bg-card/50 p-6 text-center backdrop-blur-sm transition-colors hover:border-primary/30 hover:bg-card/80"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
whileHover={{ rotate: [0, -10, 10, 0] }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Icon size={24} weight="light" />
|
||||
</motion.div>
|
||||
<h3 className="font-semibold tracking-tight">{title}</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
export const DonationBanner = () => (
|
||||
<section className="relative overflow-hidden bg-linear-to-b from-background via-primary/2 to-background py-24">
|
||||
{/* Background decorative elements */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<FloatingIcon icon={HeartIcon} className="top-[20%] left-[10%]" delay={0} />
|
||||
<FloatingIcon icon={SparkleIcon} className="top-[15%] right-[15%]" delay={0.5} />
|
||||
<FloatingIcon icon={UsersIcon} className="bottom-[25%] left-[8%]" delay={1} />
|
||||
<FloatingIcon icon={WrenchIcon} className="right-[12%] bottom-[30%]" delay={1.5} />
|
||||
<FloatingIcon icon={RocketIcon} className="top-[35%] right-[25%]" delay={2} />
|
||||
<FloatingIcon icon={HeartIcon} className="bottom-[20%] left-[20%]" delay={2.5} />
|
||||
|
||||
{/* Gradient Orbs */}
|
||||
<div className="absolute top-1/4 -left-32 size-64 rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="absolute -right-32 bottom-1/4 size-64 rounded-full bg-rose-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="container relative px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div aria-hidden="true" className="relative mb-6">
|
||||
<PulsingHeart />
|
||||
<SparkleEffect className="-top-2 -right-4" />
|
||||
<SparkleEffect className="bottom-0 -left-3" />
|
||||
</div>
|
||||
|
||||
<motion.h2
|
||||
className="mb-6 font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Trans>Support Reactive Resume</Trans>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="max-w-3xl text-base text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source project, built with love and maintained by me and a community of
|
||||
contributors. Your donations help keep the lights on and the code flowing.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="mx-auto my-12 grid max-w-5xl gap-8 sm:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={RocketIcon}
|
||||
title={t`Long-term Sustainability`}
|
||||
description={t`Your support ensures the project remains free and accessible for everyone, now and in the future.`}
|
||||
delay={0.3}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={WrenchIcon}
|
||||
title={t`Ongoing Maintenance`}
|
||||
description={t`Contributions fund bug fixes, security updates, and continuous improvements to keep the app running smoothly.`}
|
||||
delay={0.4}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={UsersIcon}
|
||||
title={t`Grow the Team`}
|
||||
description={t`Help me bring more experienced contributors on board, reducing the burden on a single maintainer and accelerating development.`}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center gap-4 sm:flex-row"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
>
|
||||
<Button asChild size="lg" className="h-11 gap-2 px-6">
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
|
||||
Open Collective
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" className="h-11 gap-2 px-6">
|
||||
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener noreferrer">
|
||||
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
|
||||
GitHub Sponsors
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer note */}
|
||||
<motion.p
|
||||
className="mt-8 text-center text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
>
|
||||
<Trans>
|
||||
Every contribution, big or small, makes a huge difference to the project.
|
||||
<br />
|
||||
Thank you for your support!
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { buttonVariants } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/animate-ui/components/radix/accordion";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const crowdinUrl = "https://crowdin.com/project/reactive-resume";
|
||||
|
||||
type FAQItemData = {
|
||||
question: string;
|
||||
answer: React.ReactNode;
|
||||
};
|
||||
|
||||
const getFaqItems = (): FAQItemData[] => [
|
||||
{
|
||||
question: t`Is Reactive Resume really free?`,
|
||||
answer: t`Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.`,
|
||||
},
|
||||
{
|
||||
question: t`How is my data protected?`,
|
||||
answer: t`Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.`,
|
||||
},
|
||||
{
|
||||
question: t`Can I export my resume to PDF?`,
|
||||
answer: t`Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.`,
|
||||
},
|
||||
{
|
||||
question: t`Is Reactive Resume available in multiple languages?`,
|
||||
answer: (
|
||||
<Trans>
|
||||
Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings
|
||||
page, or using the language switcher in the top right corner. If you don't see your language, or you would like
|
||||
to improve the existing translations, you can{" "}
|
||||
<a
|
||||
href={crowdinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
<span className="sr-only"> (opens in new tab)</span>
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What makes Reactive Resume different from other resume builders?`,
|
||||
answer: t`Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.`,
|
||||
},
|
||||
{
|
||||
question: t`Can I customize the templates?`,
|
||||
answer: t`Yes! Every template is fully customizable. You can change colors, fonts, spacing, and even write custom CSS for complete control over your resume's appearance.`,
|
||||
},
|
||||
{
|
||||
question: t`How do I share my resume?`,
|
||||
answer: t`You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!`,
|
||||
},
|
||||
];
|
||||
|
||||
export function FAQ() {
|
||||
const faqItems = getFaqItems();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="frequently-asked-questions"
|
||||
className="flex flex-col gap-x-16 gap-y-6 p-4 md:p-8 lg:flex-row lg:gap-x-24 xl:py-16"
|
||||
>
|
||||
<motion.h2
|
||||
className={cn(
|
||||
"font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl",
|
||||
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
|
||||
)}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Trans context="Every word needs to be wrapped in a tag">
|
||||
<span>Frequently</span>
|
||||
<span>Asked</span>
|
||||
<span>Questions</span>
|
||||
</Trans>
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
className="max-w-2xl flex-1 lg:ml-auto 2xl:max-w-3xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Accordion type="multiple">
|
||||
{faqItems.map((item, index) => (
|
||||
<FAQItemComponent key={item.question} item={item} index={index} />
|
||||
))}
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type FAQItemComponentProps = {
|
||||
item: FAQItemData;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="last:border-b"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
>
|
||||
<AccordionItem value={item.question} className="group border-t">
|
||||
<AccordionTrigger className="py-5">
|
||||
{item.question}
|
||||
<CaretRightIcon aria-hidden="true" className="shrink-0 transition-transform duration-200" />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CloudArrowUpIcon,
|
||||
CodeSimpleIcon,
|
||||
CurrencyDollarIcon,
|
||||
DatabaseIcon,
|
||||
DotsThreeIcon,
|
||||
FileCssIcon,
|
||||
FilesIcon,
|
||||
GithubLogoIcon,
|
||||
GlobeIcon,
|
||||
type Icon,
|
||||
KeyIcon,
|
||||
LayoutIcon,
|
||||
LockSimpleIcon,
|
||||
PaletteIcon,
|
||||
ProhibitIcon,
|
||||
ShieldCheckIcon,
|
||||
TranslateIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Feature = {
|
||||
id: string;
|
||||
icon: Icon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FeatureCardProps = Feature & {
|
||||
index: number;
|
||||
};
|
||||
|
||||
const getFeatures = (): Feature[] => [
|
||||
{
|
||||
id: "free",
|
||||
icon: CurrencyDollarIcon,
|
||||
title: t`Free`,
|
||||
description: t`Completely free, forever, no hidden costs.`,
|
||||
},
|
||||
{
|
||||
id: "open-source",
|
||||
icon: GithubLogoIcon,
|
||||
title: t`Open Source`,
|
||||
description: t`By the community, for the community.`,
|
||||
},
|
||||
{
|
||||
id: "no-ads",
|
||||
icon: ProhibitIcon,
|
||||
title: t`No Advertising, No Tracking`,
|
||||
description: t`For a secure and distraction-free experience.`,
|
||||
},
|
||||
{
|
||||
id: "data-security",
|
||||
icon: DatabaseIcon,
|
||||
title: t`Data Security`,
|
||||
description: t`Your data is secure, and never shared or sold to anyone.`,
|
||||
},
|
||||
{
|
||||
id: "self-host",
|
||||
icon: CloudArrowUpIcon,
|
||||
title: t`Self-Host with Docker`,
|
||||
description: t`You also have the option to deploy on your own servers using the Docker image.`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
icon: TranslateIcon,
|
||||
title: t`Multilingual`,
|
||||
description: t`Available in multiple languages. If you would like to contribute, check out Crowdin.`,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
icon: KeyIcon,
|
||||
title: t`One-Click Sign-In`,
|
||||
description: t`Sign in with GitHub, Google or a custom OAuth provider.`,
|
||||
},
|
||||
{
|
||||
id: "2fa",
|
||||
icon: ShieldCheckIcon,
|
||||
title: t`Passkeys & 2FA`,
|
||||
description: t`Enhance the security of your account with additional layers of protection.`,
|
||||
},
|
||||
{
|
||||
id: "unlimited-resumes",
|
||||
icon: FilesIcon,
|
||||
title: t`Unlimited Resumes`,
|
||||
description: t`Create as many resumes as you want, without limits.`,
|
||||
},
|
||||
{
|
||||
id: "design",
|
||||
icon: PaletteIcon,
|
||||
title: t`Flexibility`,
|
||||
description: t`Personalize your resume with any colors, fonts or designs, and make it your own.`,
|
||||
},
|
||||
{
|
||||
id: "css",
|
||||
icon: FileCssIcon,
|
||||
title: t`Custom CSS`,
|
||||
description: t`Write your own CSS (or use an AI to generate it for you) to customize your resume to the fullest.`,
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
icon: LayoutIcon,
|
||||
title: t`12+ Templates`,
|
||||
description: t`Beautiful templates to choose from, with more on the way.`,
|
||||
},
|
||||
{
|
||||
id: "public",
|
||||
icon: GlobeIcon,
|
||||
title: t`Shareable Links`,
|
||||
description: t`Share your resume with a public URL, and let others view it.`,
|
||||
},
|
||||
{
|
||||
id: "password-protection",
|
||||
icon: LockSimpleIcon,
|
||||
title: t`Password Protection`,
|
||||
description: t`Protect your resume with a password, and let only people with the password view it.`,
|
||||
},
|
||||
{
|
||||
id: "api-access",
|
||||
icon: CodeSimpleIcon,
|
||||
title: t`API Access`,
|
||||
description: t`Access your resumes and data programmatically using the API.`,
|
||||
},
|
||||
{
|
||||
id: "more",
|
||||
icon: DotsThreeIcon,
|
||||
title: t`And many more...`,
|
||||
description: t`New features are constantly being added and improved, so be sure to check back often.`,
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description, index }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300",
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
"hover:bg-secondary/30",
|
||||
)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.03, ease: "easeOut" }}
|
||||
>
|
||||
{/* Hover gradient overlay */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-linear-to-br from-primary/5 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div aria-hidden="true" className="relative">
|
||||
<div className="inline-flex rounded-lg bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<Icon size={24} weight="thin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex flex-col gap-y-1.5">
|
||||
<h3 className="font-semibold text-base tracking-tight transition-colors group-hover:text-primary">{title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="space-y-4 p-4 md:p-8 xl:py-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Features</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-2xl text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Everything you need to create, customize, and share professional resumes. Built with privacy in mind,
|
||||
powered by open source, and completely free forever.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 border-t xl:grid-cols-4">
|
||||
{getFeatures().map((feature, index) => (
|
||||
<FeatureCard key={feature.id} {...feature} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { BrandIcon } from "@/components/ui/brand-icon";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
|
||||
type FooterLinkItem = {
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type FooterLinkGroupProps = {
|
||||
title: string;
|
||||
links: FooterLinkItem[];
|
||||
};
|
||||
|
||||
type SocialLink = {
|
||||
url: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const getResourceLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://docs.rxresu.me", label: t`Documentation` },
|
||||
{ url: "https://github.com/AmruthPillai/Reactive-Resume", label: t`Source Code` },
|
||||
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
|
||||
{ url: "https://opencollective.com/reactive-resume", label: t`Donate` },
|
||||
];
|
||||
|
||||
const getCommunityLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://github.com/AmruthPillai/Reactive-Resume/discussions", label: t`Discussions` },
|
||||
{ url: "https://github.com/AmruthPillai/Reactive-Resume/issues", label: t`Report an Issue` },
|
||||
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
|
||||
{ url: "https://github.com/AmruthPillai/Reactive-Resume/blob/main/CONTRIBUTING.md", label: t`Contributing` },
|
||||
];
|
||||
|
||||
const socialLinks: SocialLink[] = [
|
||||
{ url: "https://github.com/AmruthPillai/Reactive-Resume", label: "GitHub", icon: GithubLogoIcon },
|
||||
{ url: "https://linkedin.com/in/amruthpillai", label: "LinkedIn", icon: LinkedinLogoIcon },
|
||||
{ url: "https://x.com/KingOKings", label: "X (Twitter)", icon: XLogoIcon },
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<motion.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<BrandIcon variant="logo" className="size-10" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-bold text-lg tracking-tight">Reactive Resume</h2>
|
||||
<p className="max-w-xs text-muted-foreground text-sm leading-relaxed">
|
||||
<Trans>
|
||||
A free and open-source resume builder that simplifies the process of creating, updating, and sharing
|
||||
your resume.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{socialLinks.map((social) => (
|
||||
<Button key={social.label} size="icon-sm" variant="ghost" asChild>
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${social.label} (${t`opens in new tab`})`}
|
||||
>
|
||||
<social.icon aria-hidden="true" size={18} />
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources Column */}
|
||||
<FooterLinkGroup title={t`Resources`} links={getResourceLinks()} />
|
||||
|
||||
{/* Community Column */}
|
||||
<FooterLinkGroup title={t`Community`} links={getCommunityLinks()} />
|
||||
|
||||
{/* Copyright Column */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</motion.footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium text-muted-foreground text-sm tracking-tight">{title}</h2>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<FooterLink key={link.url} url={link.url} label={link.label} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ url, label }: FooterLinkItem) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<li className="relative" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative inline-block text-sm transition-colors hover:text-foreground"
|
||||
>
|
||||
{label}
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute -bottom-0.5 left-0 h-px rounded bg-primary"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion, useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { GithubStarsButton } from "@/components/input/github-stars-button";
|
||||
import { LocaleCombobox } from "@/components/locale/combobox";
|
||||
import { ThemeToggleButton } from "@/components/theme/toggle-button";
|
||||
import { BrandIcon } from "@/components/ui/brand-icon";
|
||||
import { ProductHuntBanner } from "./product-hunt-banner";
|
||||
|
||||
export function Header() {
|
||||
const y = useMotionValue(0);
|
||||
const lastScroll = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
const springY = useSpring(y, { stiffness: 300, damping: 40 });
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not required to be exhaustive
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
function onScroll() {
|
||||
const current = window.scrollY ?? 0;
|
||||
if (!ticking.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (current > 32 && current > lastScroll.current) {
|
||||
// Scrolling down, hide
|
||||
y.set(-100);
|
||||
} else {
|
||||
// Scrolling up, show
|
||||
y.set(0);
|
||||
}
|
||||
lastScroll.current = current;
|
||||
ticking.current = false;
|
||||
});
|
||||
ticking.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
style={{ y: springY }}
|
||||
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
>
|
||||
<ProductHuntBanner />
|
||||
|
||||
<nav aria-label={t`Main navigation`} className="container mx-auto flex items-center gap-x-4 p-3 lg:px-12">
|
||||
<Link to="/" className="transition-opacity hover:opacity-80" aria-label={t`Reactive Resume - Go to homepage`}>
|
||||
<BrandIcon className="size-10" />
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
<LocaleCombobox
|
||||
buttonProps={{
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
className: "justify-center",
|
||||
"aria-label": t`Change language`,
|
||||
children: () => <TranslateIcon aria-hidden="true" />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThemeToggleButton />
|
||||
|
||||
<div className="hidden items-center gap-x-4 sm:flex">
|
||||
<GithubStarsButton />
|
||||
|
||||
<Button asChild size="icon" aria-label={t`Go to dashboard`}>
|
||||
<Link to="/dashboard">
|
||||
<ArrowRightIcon aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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 { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
import { Spotlight } from "@/components/animation/spotlight";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative flex min-h-svh w-svw flex-col items-center justify-center overflow-hidden border-b py-24"
|
||||
>
|
||||
<Spotlight />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="relative -mb-12 3xl:max-w-7xl max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<video
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
src="/videos/timelapse.webm"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none aspect-video size-full rounded-lg border object-cover shadow-2xl"
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-40% via-transparent to-background"
|
||||
/>
|
||||
</CometCard>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 xs:px-0 text-center">
|
||||
{/* Badge */}
|
||||
<motion.a
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.rxresu.me/getting-started"
|
||||
>
|
||||
<Badge variant="secondary" className="h-auto gap-1.5 px-3 py-0.5">
|
||||
<SparkleIcon aria-hidden="true" className="size-3.5" weight="fill" />
|
||||
<Trans>What's new in the latest version?</Trans>
|
||||
</Badge>
|
||||
</motion.a>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1 }}
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium text-muted-foreground tracking-tight md:text-lg">Finally,</p>
|
||||
<h1 className="mt-1 font-bold text-4xl tracking-tight md:text-5xl lg:text-6xl">
|
||||
A free and open-source resume builder
|
||||
</h1>
|
||||
</Trans>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
className="max-w-xl text-base text-muted-foreground leading-relaxed md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.2 }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
and sharing your resume.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 1.4 }}
|
||||
>
|
||||
<Button asChild size="lg" className="group relative overflow-hidden px-4">
|
||||
<Link to="/dashboard">
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Trans>Get Started</Trans>
|
||||
<ArrowRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" variant="ghost" className="gap-2 px-4">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<BookIcon aria-hidden="true" className="size-4" />
|
||||
<Trans>Learn More</Trans>
|
||||
<span className="sr-only">
|
||||
<Trans>(opens in new tab)</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - decorative */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2, duration: 1 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { TextMaskEffect } from "@/components/animation/text-mask";
|
||||
|
||||
export function Prefooter() {
|
||||
return (
|
||||
<section id="prefooter" className="relative overflow-hidden py-16 md:py-24">
|
||||
{/* Background decoration */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute top-0 left-1/4 size-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="absolute right-1/4 bottom-0 size-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-8">
|
||||
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
|
||||
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl space-y-8 px-6 text-center md:px-8 xl:px-0"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-bold text-2xl tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Reactive Resume continues to grow thanks to its vibrant community. This project owes its progress to
|
||||
numerous individuals who've dedicated their time and skills to make it better. We celebrate the coders
|
||||
who've enhanced its features on GitHub, the linguists whose translations on Crowdin have made it
|
||||
accessible to a broader audience, and the people who've donated to support its continued development.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HeartIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PH_LAUNCH_START = Date.UTC(2026, 0, 22, 8, 1, 0);
|
||||
const PH_LAUNCH_END = Date.UTC(2026, 0, 23, 8, 1, 0);
|
||||
|
||||
function isWithinProductHuntLaunchWindow() {
|
||||
const nowUtc = Date.now();
|
||||
return nowUtc >= PH_LAUNCH_START && nowUtc < PH_LAUNCH_END;
|
||||
}
|
||||
|
||||
export function ProductHuntBanner() {
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client
|
||||
if (!isWithinProductHuntLaunchWindow()) {
|
||||
setShowBanner(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowBanner(true);
|
||||
}, []);
|
||||
|
||||
if (!showBanner) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.producthunt.com/products/reactive-resume/launches/reactive-resume-v5"
|
||||
className="flex h-8 items-center justify-center bg-secondary text-center font-medium text-[0.85rem] text-secondary-foreground tracking-tight underline-offset-2 hover:underline"
|
||||
>
|
||||
<Trans>Reactive Resume is launching on Product Hunt today, head over to show some love!</Trans>
|
||||
<HeartIcon weight="fill" color="#DA552F" className="ml-2 size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { FileTextIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { CountUp } from "@/components/animation/count-up";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
type Statistic = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const getStatistics = (userCount: number, resumeCount: number): Statistic[] => [
|
||||
{
|
||||
id: "users",
|
||||
label: t`Users`,
|
||||
value: userCount,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
id: "resumes",
|
||||
label: t`Resumes`,
|
||||
value: resumeCount,
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
type StatisticCardProps = {
|
||||
statistic: Statistic;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
const Icon = statistic.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative flex flex-col items-center justify-center gap-y-4 border-r border-b p-8 transition-colors last:border-r-0 hover:bg-secondary/30 sm:border-b-0 xl:py-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1, ease: "easeOut" }}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-primary/2"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.1 + 0.2 }}
|
||||
>
|
||||
<Icon size={180} weight="thin" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="relative rounded-full bg-primary/10 p-3 text-primary"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||
>
|
||||
<Icon size={24} weight="thin" />
|
||||
</motion.div>
|
||||
|
||||
{/* Value */}
|
||||
<CountUp
|
||||
key={statistic.id}
|
||||
separator=","
|
||||
duration={0.8}
|
||||
to={statistic.value}
|
||||
className="font-bold text-5xl tracking-tight md:text-6xl"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<p className="relative font-medium text-base text-muted-foreground tracking-tight">{statistic.label}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Statistics() {
|
||||
const [userCountResult, resumeCountResult] = useQueries({
|
||||
queries: [orpc.statistics.user.getCount.queryOptions(), orpc.statistics.resume.getCount.queryOptions()],
|
||||
});
|
||||
|
||||
if (!userCountResult.data || !resumeCountResult.data) return null;
|
||||
|
||||
return (
|
||||
<section id="statistics" aria-labelledby="stats-heading">
|
||||
<h2 id="stats-heading" className="sr-only">
|
||||
{t`Application Statistics`}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||
{getStatistics(userCountResult.data, resumeCountResult.data).map((statistic, index) => (
|
||||
<StatisticCard key={statistic.id} statistic={statistic} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
|
||||
type TemplateItemProps = {
|
||||
metadata: TemplateMetadata;
|
||||
};
|
||||
|
||||
function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative shrink-0"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.08, zIndex: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-lg border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
|
||||
{/* Subtle overlay on hover */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* Template name on hover */}
|
||||
<div className="absolute right-0 bottom-0 left-0 translate-y-full p-4 transition-transform duration-300 group-hover:translate-y-0">
|
||||
<p className="font-semibold text-white drop-shadow-lg">{metadata.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Shine effect on hover */}
|
||||
<div className="pointer-events-none absolute inset-0 -translate-x-full rotate-12 bg-linear-to-r from-transparent via-white/10 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type MarqueeRowProps = {
|
||||
templates: Array<[string, TemplateMetadata]>;
|
||||
rowId: string;
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex gap-x-4 will-change-transform sm:gap-x-6"
|
||||
animate={{ x: animateX }}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
duration,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{templates.map(([template, metadata], index) => (
|
||||
<TemplateItem key={`${rowId}-${template}-${index}`} metadata={metadata} />
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Templates() {
|
||||
// Split templates into two rows and duplicate for seamless infinite scroll
|
||||
const { row1, row2 } = useMemo(() => {
|
||||
const entries = Object.entries(templates);
|
||||
const half = Math.ceil(entries.length / 2);
|
||||
const firstHalf = entries.slice(0, half);
|
||||
const secondHalf = entries.slice(half);
|
||||
|
||||
// Duplicate each row for seamless scrolling
|
||||
return {
|
||||
row1: [...firstHalf, ...firstHalf],
|
||||
row2: [...secondHalf, ...secondHalf],
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Templates</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-2xl text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Explore our diverse selection of templates, each designed to fit different styles, professions, and
|
||||
personalities. Reactive Resume currently offers 12 templates, with more on the way.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative mt-8 -rotate-3 py-8 sm:-rotate-4 lg:mt-0 lg:-rotate-5">
|
||||
{/* Marquee container with minimum height */}
|
||||
<div className="flex min-h-[280px] flex-col gap-y-4 sm:min-h-[320px] sm:gap-y-6 md:min-h-[380px] lg:min-h-[420px]">
|
||||
{/* First row - moves left to right */}
|
||||
<MarqueeRow templates={row1} rowId="row1" direction="left" duration={45} />
|
||||
|
||||
{/* Second row - moves right to left (opposite direction) */}
|
||||
<MarqueeRow templates={row2} rowId="row2" direction="right" duration={50} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const email = "hello@amruthpillai.com";
|
||||
|
||||
const testimonials: string[] = [
|
||||
"Great site. Love the interactive interface. You can tell it's designed by someone who wants to use it.",
|
||||
|
||||
"Truly everything about the UX is so intuitive, fluid and lets you customize your CV how you want and so rapidly. I thank you so much for putting the work to release something like this.",
|
||||
|
||||
"I want to appreciate you for making your projects #openSource, most especially your Reactive Resume, which is the handiest truly-free resume maker I've come across. This is a big shoutout to you. Well done!",
|
||||
|
||||
"I'd like to appreciate the great work you've done with rxresu.me. The website's design, smooth functionality, and ease of use under the free plan are really impressive. It's clear that a lot of thought and effort has gone into building and maintaining such a useful platform.",
|
||||
|
||||
" I just wanted to reach you out and thank you personally for your wonderful project rxresu.me. It is very valuable, and the fact that it is open source, makes it all the more meaningful, since there are lots of people who struggle to make their CV look good. For my part, it saved me a lot of time and helped me shape my CV in a very efficient way.",
|
||||
|
||||
"I appreciate your effort in open-sourcing and making it free for everyone to use, it's a great effort. By using this platform, I got a job secured in the government sector of Oman, that too in a ministry. Thank you for providing this platform. Keep going, appreciate the effort. ❤️",
|
||||
|
||||
"Your CV generator just saved my day! Thank you so much, great work!",
|
||||
|
||||
"I want to express my heartfelt gratitude and admiration for your incredible work and remarkable skills. Your projects, especially the Resume Builder, have been immensely helpful to me, and I deeply appreciate the effort and creativity you've poured into them.",
|
||||
|
||||
"Hey! Thank you so much for making this fantastic tool! It helped me get a new job as a Research Software Engineer at Arizona State University.",
|
||||
|
||||
"Wow, what an impressive profile! You are very talented. I'm also a fellow SWE on the job hunt and I came across a linked to Reactive Resume on Reddit and gave it a shot. This could easily be a paid product. Very clean and useful.",
|
||||
|
||||
"Thank you for creating Reactive Resume. It is an amazing product, and I love the design and how it simplifies the resume-making experience. I've been trying to create a good resume for a decade to find my first job in tech, and your tool has been incredibly helpful.",
|
||||
];
|
||||
|
||||
type TestimonialCardProps = {
|
||||
testimonial: string;
|
||||
};
|
||||
|
||||
function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
|
||||
initial={{ scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
>
|
||||
<div className="relative flex h-full flex-col rounded-xl border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
|
||||
<p className="flex-1 text-muted-foreground leading-relaxed">"{testimonial}"</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type MarqueeRowProps = {
|
||||
rowId: string;
|
||||
testimonials: string[];
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeRow({ testimonials, rowId, direction, duration = 30 }: MarqueeRowProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-start gap-x-4 will-change-transform"
|
||||
animate={{ x: animateX }}
|
||||
transition={{ x: { repeat: Infinity, repeatType: "loop", duration, ease: "linear" } }}
|
||||
>
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<TestimonialCard key={`${rowId}-${index}`} testimonial={testimonial} />
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Testimonials() {
|
||||
const { row1, row2 } = useMemo(() => {
|
||||
const half = Math.ceil(testimonials.length / 2);
|
||||
const firstHalf = testimonials.slice(0, half);
|
||||
const secondHalf = testimonials.slice(half);
|
||||
|
||||
return {
|
||||
row1: [...firstHalf, ...firstHalf],
|
||||
row2: [...secondHalf, ...secondHalf],
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
|
||||
<motion.div
|
||||
className="mb-10 flex flex-col items-center space-y-4 px-4 text-center md:px-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Testimonials</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-4xl text-balance text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
A lot of people have written to me over the years to share their experiences with Reactive Resume and how it
|
||||
has helped them, and I never get tired of reading them. If you have a story to share, let me know by sending
|
||||
me an email at{" "}
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-2 transition-colors hover:text-primary"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Left fade */}
|
||||
<div className="pointer-events-none absolute top-0 bottom-0 left-0 z-10 w-16 bg-linear-to-r from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
|
||||
|
||||
{/* Right fade */}
|
||||
<div className="pointer-events-none absolute top-0 right-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
|
||||
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<MarqueeRow testimonials={row1} rowId="row1" direction="left" duration={50} />
|
||||
<MarqueeRow testimonials={row2} rowId="row2" direction="right" duration={55} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { DonationBanner } from "./-sections/donate";
|
||||
import { FAQ } from "./-sections/faq";
|
||||
import { Features } from "./-sections/features";
|
||||
import { Footer } from "./-sections/footer";
|
||||
import { Hero } from "./-sections/hero";
|
||||
import { Prefooter } from "./-sections/prefooter";
|
||||
import { Statistics } from "./-sections/statistics";
|
||||
import { Templates } from "./-sections/templates";
|
||||
import { Testimonials } from "./-sections/testimonials";
|
||||
|
||||
export const Route = createFileRoute("/_home/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<main id="main-content" className="relative">
|
||||
<Hero />
|
||||
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-12">
|
||||
<div className="border-border border-x [&>section:first-child]:border-t-0 [&>section]:border-border [&>section]:border-t">
|
||||
<Statistics />
|
||||
<Features />
|
||||
<Templates />
|
||||
<Testimonials />
|
||||
<DonationBanner />
|
||||
<FAQ />
|
||||
<Prefooter />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { Header } from "./-sections/header";
|
||||
|
||||
export const Route = createFileRoute("/_home")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-100 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:shadow-lg focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<Header />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@/integrations/auth/config";
|
||||
|
||||
function handler({ request }: { request: Request }) {
|
||||
return auth.handler(request);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
POST: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/integrations/drizzle/client";
|
||||
import { getStorageService } from "@/integrations/orpc/services/storage";
|
||||
|
||||
function isUnhealthy(check: unknown): boolean {
|
||||
return (
|
||||
!!check &&
|
||||
typeof check === "object" &&
|
||||
"status" in check &&
|
||||
typeof check.status === "string" &&
|
||||
check.status === "unhealthy"
|
||||
);
|
||||
}
|
||||
|
||||
async function handler() {
|
||||
const checks = {
|
||||
version: process.env.npm_package_version,
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database: await checkDatabase(),
|
||||
storage: await checkStorage(),
|
||||
};
|
||||
|
||||
if (checks.status === "unhealthy" || Object.values(checks).some(isUnhealthy)) {
|
||||
checks.status = "unhealthy";
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
const body = JSON.stringify(checks);
|
||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
||||
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
|
||||
|
||||
return new Response(body, {
|
||||
headers,
|
||||
status: checks.status === "unhealthy" ? 500 : 200,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
const result = await storageService.healthcheck();
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { RequestHeadersPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import router from "@/integrations/orpc/router";
|
||||
import { env } from "@/utils/env";
|
||||
import { getLocale } from "@/utils/locale";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(error);
|
||||
}),
|
||||
],
|
||||
plugins: [
|
||||
new RequestHeadersPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const locale = await getLocale();
|
||||
|
||||
if (request.method === "GET" && request.url.endsWith("/spec.json")) {
|
||||
const spec = await openAPIGenerator.generate(router, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: "5.0.0",
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
|
||||
return Response.json(spec);
|
||||
}
|
||||
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale, reqHeaders: request.headers },
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return new Response("NOT_FOUND", { status: 404 });
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/openapi/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
ANY: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin } from "@orpc/server/plugins";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import router from "@/integrations/orpc/router";
|
||||
import { getLocale } from "@/utils/locale";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(error);
|
||||
}),
|
||||
],
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin()],
|
||||
});
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: await getLocale() },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/rpc/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
ANY: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintSimpleIcon, GithubLogoIcon, GoogleLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function SocialAuth() {
|
||||
const router = useRouter();
|
||||
const { data: authProviders = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.passkey();
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
router.invalidate();
|
||||
};
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
router.invalidate();
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.oauth2({
|
||||
providerId: "custom",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<hr className="flex-1" />
|
||||
<span className="font-medium text-xs tracking-wide">
|
||||
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
|
||||
or continue with
|
||||
</Trans>
|
||||
</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePasskeyLogin}
|
||||
className={cn("col-span-full", "custom" in authProviders && "col-span-1")}
|
||||
>
|
||||
<FingerprintSimpleIcon />
|
||||
Passkey
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in authProviders && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{authProviders.custom}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in authProviders && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in authProviders && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
|
||||
export const Route = createFileRoute("/auth/forgot-password")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Sending password reset email...`);
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: data.email,
|
||||
redirectTo: "/auth/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
if (submitted) return <PostForgotPasswordScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Remember your password?{" "}
|
||||
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" autoComplete="email" placeholder="john.doe@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostForgotPasswordScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to reset your password.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<a href="mailto:">
|
||||
<Trans>Open Email Client</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/auth/")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import type { BetterFetchOption } from "better-auth/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
export const Route = createFileRoute("/auth/login")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
return { session: null };
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
identifier: z.string().trim().toLowerCase(),
|
||||
password: z.string().trim().min(6).max(64),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
identifier: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const fetchOptions: BetterFetchOption = {
|
||||
onSuccess: (context) => {
|
||||
// Check if 2FA is required
|
||||
if (context.data && "twoFactorRedirect" in context.data && context.data.twoFactorRedirect) {
|
||||
toast.dismiss(toastId);
|
||||
navigate({ to: "/auth/verify-2fa", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal login success
|
||||
router.invalidate();
|
||||
toast.dismiss(toastId);
|
||||
navigate({ to: "/dashboard", replace: true });
|
||||
},
|
||||
onError: ({ error }) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
};
|
||||
|
||||
if (data.identifier.includes("@")) {
|
||||
await authClient.signIn.email({
|
||||
email: data.identifier,
|
||||
password: data.password,
|
||||
fetchOptions,
|
||||
});
|
||||
} else {
|
||||
await authClient.signIn.username({
|
||||
username: data.identifier,
|
||||
password: data.password,
|
||||
fetchOptions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
|
||||
<Link to="/auth/register">
|
||||
Create one now <ArrowRightIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" placeholder="john.doe@example.com" className="lowercase" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>You can also use your username to login.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button asChild tabIndex={-1} variant="link" className="h-auto p-0 text-xs leading-none">
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Forgot Password?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl>
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { SocialAuth } from "./-components/social-auth";
|
||||
|
||||
export const Route = createFileRoute("/auth/register")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
return { session: null };
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().toLowerCase(),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Signing up...`);
|
||||
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
username: data.username,
|
||||
displayUsername: data.username,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
if (submitted) return <PostSignupScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Create a new account</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
|
||||
<Link to="/auth/login">
|
||||
Sign in now <ArrowRightIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input min={3} max={64} autoComplete="name" placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="username"
|
||||
placeholder="john.doe"
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl>
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSignupScreen() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>You've got mail!</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Check your email for a link to verify your account.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
<Trans>This step is optional, but recommended.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Verifying your email is required when resetting your password.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/dashboard">
|
||||
<Trans>Continue</Trans> <ArrowRightIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, redirect, SearchParamError, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
|
||||
const searchSchema = z.object({ token: z.string().min(1) });
|
||||
|
||||
export const Route = createFileRoute("/auth/reset-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/auth/login" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { token } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Resetting your password...`);
|
||||
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: data.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been reset successfully. You can now sign in with your new password.`, {
|
||||
id: toastId,
|
||||
});
|
||||
navigate({ to: "/auth/login" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Reset your password</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Please enter a new password for your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl>
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans>Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect, SearchParamError, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^/]+\/[^/]+$/),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/auth/resume-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.auth.verifyResumePassword.mutationOptions());
|
||||
|
||||
const [username, slug] = useMemo(() => {
|
||||
const [username, slug] = redirect.split("/").slice(1) as [string, string];
|
||||
if (!username || !slug) throw navigate({ to: "/" });
|
||||
return [username, slug];
|
||||
}, [redirect, navigate]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Verifying password...`);
|
||||
|
||||
verifyPassword(
|
||||
{ username, slug, password: data.password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
navigate({ to: redirect, replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "INVALID_PASSWORD") {
|
||||
toast.dismiss(toastId);
|
||||
form.setError("password", { message: t`The password you entered is incorrect` });
|
||||
} else {
|
||||
toast.error(error.message, { id: toastId });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>The resume you are trying to access is password protected</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground leading-relaxed">
|
||||
<Trans>Please enter the password shared with you by the owner of the resume to continue.</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl>
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans>Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@/components/ui/brand-icon";
|
||||
|
||||
export const Route = createFileRoute("/auth")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh w-dvw max-w-sm flex-col justify-center space-y-6 px-4 xs:px-0">
|
||||
<BrandIcon className="mb-4 size-20 self-center" />
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
|
||||
export const Route = createFileRoute("/auth/verify-2fa-backup")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().trim(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Verifying backup code...`);
|
||||
const formattedCode = `${data.code.slice(0, 5)}-${data.code.slice(5)}`;
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
router.invalidate();
|
||||
navigate({ to: "/dashboard", replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Verify with a Backup Code</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter one of your saved backup codes to access your account</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem className="justify-self-center">
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={10}
|
||||
value={field.value}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
onChange={field.onChange}
|
||||
onComplete={form.handleSubmit(onSubmit)}
|
||||
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className="size-12" />
|
||||
<InputOTPSlot index={1} className="size-12" />
|
||||
<InputOTPSlot index={2} className="size-12" />
|
||||
<InputOTPSlot index={3} className="size-12" />
|
||||
<InputOTPSlot index={4} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={5} className="size-12" />
|
||||
<InputOTPSlot index={6} className="size-12" />
|
||||
<InputOTPSlot index={7} className="size-12" />
|
||||
<InputOTPSlot index={8} className="size-12" />
|
||||
<InputOTPSlot index={9} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button type="button" variant="outline" className="flex-1" asChild>
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
|
||||
export const Route = createFileRoute("/auth/verify-2fa")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.session) throw redirect({ to: "/dashboard", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: data.code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
router.invalidate();
|
||||
toast.dismiss(toastId);
|
||||
navigate({ to: "/dashboard", replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h1>
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>Enter the verification code from your authenticator app</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem className="justify-self-center">
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
onChange={field.onChange}
|
||||
onComplete={form.handleSubmit(onSubmit)}
|
||||
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className="size-12" />
|
||||
<InputOTPSlot index={1} className="size-12" />
|
||||
<InputOTPSlot index={2} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} className="size-12" />
|
||||
<InputOTPSlot index={4} className="size-12" />
|
||||
<InputOTPSlot index={5} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button type="button" variant="outline" className="flex-1" asChild>
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Back to Login</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Button type="button" variant="link" className="h-auto justify-self-center p-0 text-sm" asChild>
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans>Lost access to your authenticator?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FilePdfIcon,
|
||||
type Icon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useControls } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, generateFilename } from "@/utils/file";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
export function BuilderDock() {
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { data: session } = authClient.useSession();
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { zoomIn, zoomOut, resetTransform, centerView } = useControls();
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } }));
|
||||
const { mutateAsync: printResumeAsPDF, isPending: isPrinting } = useMutation(
|
||||
orpc.printer.printResumeAsPDF.mutationOptions(),
|
||||
);
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session || !resume) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session, resume]);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
resetTransform();
|
||||
centerView();
|
||||
}, [resetTransform, centerView]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const { url } = await printResumeAsPDF({ id: resume.id });
|
||||
|
||||
downloadFromUrl(url, filename);
|
||||
}, [resume, printResumeAsPDF]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 0.5, y: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl"
|
||||
>
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.1)} />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
|
||||
<DockIcon icon={ArrowsClockwiseIcon} title={t`Reset zoom`} onClick={() => onReset()} />
|
||||
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
onClick={() => onDownloadPDF()}
|
||||
icon={isPrinting ? CircleNotchIcon : FilePdfIcon}
|
||||
iconClassName={cn(isPrinting && "animate-spin")}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DockIconProps = {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" className="font-medium">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
side: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BuilderSidebarEdge({ side, children }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 hidden w-12 flex-col items-center justify-between bg-popover py-2.5 sm:flex",
|
||||
side === "left" ? "left-0 border-r" : "right-0 border-l",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CopySimpleIcon,
|
||||
HouseSimpleIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
SidebarSimpleIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const name = useResumeStore((state) => state.resume.name);
|
||||
const isLocked = useResumeStore((state) => state.resume.isLocked);
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button asChild size="icon" variant="ghost">
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
<HouseSimpleIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="mr-2.5 text-muted-foreground">/</span>
|
||||
<h2 className="flex-1 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ml-2 text-muted-foreground" />}
|
||||
<BuilderHeaderDropdown />
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderHeaderDropdown() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const id = useResumeStore((state) => state.resume.id);
|
||||
const name = useResumeStore((state) => state.resume.name);
|
||||
const slug = useResumeStore((state) => state.resume.slug);
|
||||
const tags = useResumeStore((state) => state.resume.tags);
|
||||
const isLocked = useResumeStore((state) => state.resume.isLocked);
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
const handleUpdate = () => {
|
||||
openDialog("resume.update", { id, name, slug, tags });
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true });
|
||||
};
|
||||
|
||||
const handleToggleLock = async () => {
|
||||
if (!isLocked) {
|
||||
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
|
||||
description: t`When locked, the resume cannot be updated or deleted.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
}
|
||||
|
||||
setLockedResume(
|
||||
{ id, isLocked: !isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
|
||||
description: t`This action cannot be undone.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your resume...`);
|
||||
|
||||
deleteResume(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={isLocked} onSelect={handleUpdate}>
|
||||
<PencilSimpleLineIcon className="mr-2" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={handleDuplicate}>
|
||||
<CopySimpleIcon className="mr-2" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={handleToggleLock}>
|
||||
{isLocked ? <LockSimpleOpenIcon className="mr-2" /> : <LockSimpleIcon className="mr-2" />}
|
||||
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={isLocked} onSelect={handleDelete}>
|
||||
<TrashSimpleIcon className="mr-2" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
|
||||
import { getSectionIcon, getSectionTitle, type LeftSidebarSection, leftSidebarSections } from "@/utils/resume/section";
|
||||
import { getInitials } from "@/utils/string";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { AwardsSectionBuilder } from "./sections/awards";
|
||||
import { BasicsSectionBuilder } from "./sections/basics";
|
||||
import { CertificationsSectionBuilder } from "./sections/certifications";
|
||||
import { CustomSectionBuilder } from "./sections/custom";
|
||||
import { EducationSectionBuilder } from "./sections/education";
|
||||
import { ExperienceSectionBuilder } from "./sections/experience";
|
||||
import { InterestsSectionBuilder } from "./sections/interests";
|
||||
import { LanguagesSectionBuilder } from "./sections/languages";
|
||||
import { PictureSectionBuilder } from "./sections/picture";
|
||||
import { ProfilesSectionBuilder } from "./sections/profiles";
|
||||
import { ProjectsSectionBuilder } from "./sections/projects";
|
||||
import { PublicationsSectionBuilder } from "./sections/publications";
|
||||
import { ReferencesSectionBuilder } from "./sections/references";
|
||||
import { SkillsSectionBuilder } from "./sections/skills";
|
||||
import { SummarySectionBuilder } from "./sections/summary";
|
||||
import { VolunteerSectionBuilder } from "./sections/volunteer";
|
||||
|
||||
function getSectionComponent(type: LeftSidebarSection) {
|
||||
return match(type)
|
||||
.with("picture", () => <PictureSectionBuilder />)
|
||||
.with("basics", () => <BasicsSectionBuilder />)
|
||||
.with("summary", () => <SummarySectionBuilder />)
|
||||
.with("profiles", () => <ProfilesSectionBuilder />)
|
||||
.with("experience", () => <ExperienceSectionBuilder />)
|
||||
.with("education", () => <EducationSectionBuilder />)
|
||||
.with("projects", () => <ProjectsSectionBuilder />)
|
||||
.with("skills", () => <SkillsSectionBuilder />)
|
||||
.with("languages", () => <LanguagesSectionBuilder />)
|
||||
.with("interests", () => <InterestsSectionBuilder />)
|
||||
.with("awards", () => <AwardsSectionBuilder />)
|
||||
.with("certifications", () => <CertificationsSectionBuilder />)
|
||||
.with("publications", () => <PublicationsSectionBuilder />)
|
||||
.with("volunteer", () => <VolunteerSectionBuilder />)
|
||||
.with("references", () => <ReferencesSectionBuilder />)
|
||||
.with("custom", () => <CustomSectionBuilder />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function BuilderSidebarLeft() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ml-12">
|
||||
<div className="space-y-4 p-4">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: LeftSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("left", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<BuilderSidebarEdge side="left">
|
||||
<div />
|
||||
|
||||
<div className="flex flex-col justify-center gap-y-2">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<UserDropdownMenu>
|
||||
{({ session }) => (
|
||||
<Button size="icon" variant="ghost">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={session.user.image ?? undefined} />
|
||||
<AvatarFallback className="text-[0.5rem]">{getInitials(session.user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
)}
|
||||
</UserDropdownMenu>
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { awardItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.awards);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.awards.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="awards" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="awards" item={item} title={item.title} subtitle={item.awarder} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="awards">
|
||||
<Trans>Add a new award</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { basicsSchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { CustomFieldsSection } from "./custom-fields";
|
||||
|
||||
export function BasicsSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="basics">
|
||||
<BasicsSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = basicsSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function BasicsSectionForm() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: basics,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.basics = data;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="headline"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Headline</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Phone</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<URLInput {...field} value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CustomFieldsSection onSubmit={onSubmit} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { certificationItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.certifications);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.certifications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase
|
||||
type="certifications"
|
||||
className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}
|
||||
>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="certifications"
|
||||
item={item}
|
||||
title={item.title}
|
||||
subtitle={[item.issuer, item.date].filter(Boolean).join(" • ") || undefined}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="certifications">
|
||||
<Trans>Add a new certification</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { basicsSchema } from "@/schema/resume/data";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
type FormValues = z.infer<typeof basicsSchema>;
|
||||
type CustomField = FormValues["customFields"][number];
|
||||
|
||||
type Props = {
|
||||
onSubmit: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
export function CustomFieldsSection({ onSubmit }: Props) {
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
const customFields = useWatch({ control: form.control, name: "customFields" });
|
||||
|
||||
const customFieldsArray = useFieldArray({
|
||||
control: form.control,
|
||||
keyName: "key",
|
||||
name: "customFields",
|
||||
});
|
||||
|
||||
function handleReorder(newFields: CustomField[]) {
|
||||
const currentFieldsMap = Object.fromEntries(customFields.map((f) => [f.id, f]));
|
||||
const reordered = newFields.map((field) => currentFieldsMap[field.id] ?? field);
|
||||
form.setValue("customFields", reordered);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
function handleRemove(index: number) {
|
||||
customFieldsArray.remove(index);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
customFieldsArray.append({ id: generateId(), icon: "acorn", text: "" });
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
return (
|
||||
<Reorder.Group className="touch-none space-y-4" values={customFieldsArray.fields} onReorder={handleReorder}>
|
||||
{customFieldsArray.fields.map((field, index) => (
|
||||
<CustomFieldItem key={field.id} field={field}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`customFields.${index}.icon`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl>
|
||||
<IconPicker
|
||||
{...field}
|
||||
className="rounded-r-none! border-r-0!"
|
||||
onChange={(icon) => {
|
||||
field.onChange(icon);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`customFields.${index}.text`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="rounded-l-none!"
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" className="ml-2" onClick={() => handleRemove(index)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</CustomFieldItem>
|
||||
))}
|
||||
|
||||
<Button variant="ghost" onClick={handleAdd}>
|
||||
<ListPlusIcon />
|
||||
<Trans>Add a custom field</Trans>
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomFieldItemProps = {
|
||||
field: CustomField;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function CustomFieldItem({ field, children }: CustomFieldItemProps) {
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={field.id}
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex touch-none items-center"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="mr-2 touch-none"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
CopySimpleIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import type { CustomSection } from "@/schema/resume/data";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton } from "../shared/section-item";
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const customSections = useResumeStore((state) => state.resume.data.customSections);
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("rounded-md border", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionItem key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<SectionAddItemButton type="custom">
|
||||
<Trans>Add a new custom section</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionItem({ section }: { section: CustomSection }) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const sanitizedContent = useMemo(() => sanitizeHtml(section.content), [section.content]);
|
||||
|
||||
const onUpdate = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
openDialog("resume.sections.custom.create", section);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].hidden = !draft.customSections[sectionIndex].hidden;
|
||||
});
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].columns = parseInt(value, 10);
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
|
||||
// remove from layout
|
||||
draft.metadata.layout.pages = draft.metadata.layout.pages.filter((page) => page.main.includes(section.id));
|
||||
draft.metadata.layout.pages = draft.metadata.layout.pages.filter((page) => page.sidebar.includes(section.id));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group flex select-none border-b"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="line-clamp-1 font-medium text-base">{section.title}</div>
|
||||
<div
|
||||
className="line-clamp-2 text-muted-foreground text-xs"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onToggleVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { educationItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.education);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.education.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="education" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="education" item={item} title={item.school} subtitle={item.degree} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="education">
|
||||
<Trans>Add a new education</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { experienceItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.experience);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.experience.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="experience" item={item} title={item.company} subtitle={item.position} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="experience">
|
||||
<Trans>Add a new experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { interestItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.interests);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.interests.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="interests" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="interests" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="interests">
|
||||
<Trans>Add a new interest</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { languageItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.languages);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.languages.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="languages" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="languages" item={item} title={item.language} subtitle={item.fluency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="languages">
|
||||
<Trans>Add a new language</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { zodResolver } from "@hookform/resolvers/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 { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { pictureSchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PictureSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="picture">
|
||||
<PictureSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function PictureSectionForm() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions());
|
||||
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions());
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(pictureSchema),
|
||||
defaultValues: picture,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof pictureSchema>) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.picture = data;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectPicture = () => {
|
||||
if (!fileInputRef.current) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const onDeletePicture = () => {
|
||||
const filename = picture.url.split("/").pop();
|
||||
if (!filename) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting picture...`);
|
||||
|
||||
deleteFile(
|
||||
{ filename },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
form.setValue("url", "", { shouldDirty: true });
|
||||
form.handleSubmit(onSubmit)();
|
||||
};
|
||||
|
||||
const onUploadPicture = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const toastId = toast.loading(t`Uploading picture...`);
|
||||
|
||||
uploadFile(file, {
|
||||
onSuccess: ({ url }) => {
|
||||
form.setValue("url", url, { shouldDirty: true });
|
||||
form.handleSubmit(onSubmit)();
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
|
||||
<div
|
||||
onClick={picture.url ? onDeletePicture : onSelectPicture}
|
||||
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
{picture.url && (
|
||||
<img
|
||||
alt=""
|
||||
src={picture.url}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
form.setValue("hidden", !picture.hidden, { shouldDirty: true });
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={32}
|
||||
max={512}
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rotation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Rotation</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={360}
|
||||
step={5}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>°</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aspectRatio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Aspect Ratio</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Square`}
|
||||
onClick={() => {
|
||||
field.onChange(1);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Landscape`}
|
||||
onClick={() => {
|
||||
field.onChange(1.5);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[1.5/1] min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t`Portrait`}
|
||||
onClick={() => {
|
||||
field.onChange(0.5);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[1/1.5] min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="borderRadius"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Border Radius</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="0pt"
|
||||
onClick={() => {
|
||||
field.onChange(0);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-none border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="10pt"
|
||||
onClick={() => {
|
||||
field.onChange(10);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-[10%] border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="100pt"
|
||||
onClick={() => {
|
||||
field.onChange(100);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-full border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="borderColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0 self-end">
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
defaultValue={field.value}
|
||||
onValueChange={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="borderWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Border Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shadowColor"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0 self-end">
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
defaultValue={field.value}
|
||||
onValueChange={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shadowWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Shadow Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { profileItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.profiles);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.profiles.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="profiles" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="profiles" item={item} title={item.network} subtitle={item.username} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="profiles">
|
||||
<Trans>Add a new profile</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { projectItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.projects);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.projects.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="projects" item={item} title={item.name} subtitle={buildSubtitle(item)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="projects">
|
||||
<Trans>Add a new project</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { publicationItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.publications);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.publications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="publications" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="publications" item={item} title={item.title} subtitle={item.publisher} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="publications">
|
||||
<Trans>Add a new publication</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { referenceItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.references);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.references.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="references" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="references" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="references">
|
||||
<Trans>Add a new reference</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { skillItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.skills);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.skills.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="skills">
|
||||
<Trans>Add a new skill</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SummarySectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.summary);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.summary.content = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="summary">
|
||||
<RichInput value={section.content} onChange={onChange} />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { volunteerItemSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const section = useResumeStore((state) => state.resume.data.sections.volunteer);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.volunteer.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="volunteer" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="volunteer"
|
||||
item={item}
|
||||
title={item.organization}
|
||||
subtitle={item.location}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="volunteer">
|
||||
<Trans>Add a new volunteer experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/animate-ui/components/radix/accordion";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
import { getSectionIcon, getSectionTitle, type LeftSidebarSection } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionDropdownMenu } from "./section-menu";
|
||||
|
||||
type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
type: LeftSidebarSection;
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const section = useResumeStore((state) => {
|
||||
if (type === "basics") return state.resume.data.basics;
|
||||
if (type === "summary") return state.resume.data.summary;
|
||||
if (type === "picture") return state.resume.data.picture;
|
||||
if (type === "custom") return state.resume.data.customSections;
|
||||
return state.resume.data.sections[type];
|
||||
});
|
||||
|
||||
const isHidden = "hidden" in section && section.hidden;
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
collapsible
|
||||
type="single"
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? "" : type}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
className={cn("space-y-4", isHidden && "opacity-50")}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger asChild className="mr-2 items-center justify-center">
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
</AccordionTrigger>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4">
|
||||
{getSectionIcon(type)}
|
||||
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">
|
||||
{("title" in section && section.title) || getSectionTitle(type)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{!["picture", "basics", "custom"].includes(type) && (
|
||||
<SectionDropdownMenu type={type as "summary" | SectionType} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccordionContent
|
||||
className={cn(
|
||||
"overflow-hidden pb-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CopySimpleIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import type { SectionItem as SectionItemType, SectionType } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props<T extends SectionItemType> = {
|
||||
type: SectionType;
|
||||
item: T;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle }: Props<T>) {
|
||||
const confirm = useConfirm();
|
||||
const controls = useDragControls();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
openDialog(`resume.sections.${type}.update`, item);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
openDialog(`resume.sections.${type}.create`, item);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to delete this item?", {
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const section = draft.sections[type];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="group relative flex h-18 select-none border-b"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 group-hover:opacity-100"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
|
||||
item.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="line-clamp-1 font-medium">{title}</div>
|
||||
{subtitle && <div className="line-clamp-1 text-muted-foreground text-xs">{subtitle}</div>}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<DotsThreeVerticalIcon />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onToggleVisibility}>
|
||||
{item.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{item.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type AddButtonProps = {
|
||||
type: SectionType | "custom";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SectionAddItemButton({ type, children }: AddButtonProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleAdd = () => {
|
||||
openDialog(`resume.sections.${type}.create`, undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="flex w-full items-center gap-x-2 px-3 py-4 font-medium hover:bg-secondary/20 focus:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<PlusIcon />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BroomIcon,
|
||||
ColumnsIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
ListIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/animate-ui/components/radix/dropdown-menu";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
|
||||
type Props = {
|
||||
type: "summary" | SectionType;
|
||||
};
|
||||
|
||||
export function SectionDropdownMenu({ type }: Props) {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const section = useResumeStore((state) =>
|
||||
type === "summary" ? state.resume.data.summary : state.resume.data.sections[type],
|
||||
);
|
||||
|
||||
const onAddItem = () => {
|
||||
if (type === "summary") return;
|
||||
openDialog(`resume.sections.${type}.create`, undefined);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.hidden = !draft.summary.hidden;
|
||||
} else {
|
||||
draft.sections[type].hidden = !draft.sections[type].hidden;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onRenameSection = async () => {
|
||||
const newTitle = await prompt(t`What do you want to rename this section to?`, {
|
||||
description: t`Leave empty to reset the title to the original.`,
|
||||
defaultValue: section.title,
|
||||
});
|
||||
|
||||
if (newTitle === null || newTitle === section.title) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.title = newTitle ?? "";
|
||||
} else {
|
||||
draft.sections[type].title = newTitle ?? "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.columns = parseInt(value, 10);
|
||||
} else {
|
||||
draft.sections[type].columns = parseInt(value, 10);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const confirmed = await confirm("Are you sure you want to reset this section?", {
|
||||
description: "This will remove all items from this section.",
|
||||
confirmText: "Reset",
|
||||
cancelText: "Cancel",
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.content = "";
|
||||
} else {
|
||||
draft.sections[type].items = [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<ListIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
{type !== "summary" && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onAddItem}>
|
||||
<PlusIcon />
|
||||
<Trans>Add a new item</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={onToggleVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onSelect={onRenameSection}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onReset}>
|
||||
<BroomIcon />
|
||||
<Trans>Reset</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
getSectionIcon,
|
||||
getSectionTitle,
|
||||
type RightSidebarSection,
|
||||
rightSidebarSections,
|
||||
} from "@/utils/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { CSSSectionBuilder } from "./sections/css";
|
||||
import { DesignSectionBuilder } from "./sections/design";
|
||||
import { ExportSectionBuilder } from "./sections/export";
|
||||
import { InformationSectionBuilder } from "./sections/information";
|
||||
import { LayoutSectionBuilder } from "./sections/layout";
|
||||
import { NotesSectionBuilder } from "./sections/notes";
|
||||
import { PageSectionBuilder } from "./sections/page";
|
||||
import { SharingSectionBuilder } from "./sections/sharing";
|
||||
import { StatisticsSectionBuilder } from "./sections/statistics";
|
||||
import { TemplateSectionBuilder } from "./sections/template";
|
||||
import { TypographySectionBuilder } from "./sections/typography";
|
||||
|
||||
function getSectionComponent(type: RightSidebarSection) {
|
||||
return match(type)
|
||||
.with("template", () => <TemplateSectionBuilder />)
|
||||
.with("layout", () => <LayoutSectionBuilder />)
|
||||
.with("typography", () => <TypographySectionBuilder />)
|
||||
.with("design", () => <DesignSectionBuilder />)
|
||||
.with("page", () => <PageSectionBuilder />)
|
||||
.with("css", () => <CSSSectionBuilder />)
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
.with("statistics", () => <StatisticsSectionBuilder />)
|
||||
.with("export", () => <ExportSectionBuilder />)
|
||||
.with("information", () => <InformationSectionBuilder />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function BuilderSidebarRight() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:mr-12">
|
||||
<div className="space-y-4 p-4">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Copyright className="mx-auto py-2 text-center" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: RightSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("right", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<BuilderSidebarEdge side="right">
|
||||
<div />
|
||||
|
||||
<div className="flex flex-col justify-center gap-y-2">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div />
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
import { useEffect } from "react";
|
||||
import type { Theme } from "@/utils/theme";
|
||||
|
||||
const CSS_SELECTORS = [
|
||||
".page",
|
||||
".page-content",
|
||||
".page-header",
|
||||
".page-basics",
|
||||
".page-name",
|
||||
".page-headline",
|
||||
".page-main",
|
||||
".page-sidebar",
|
||||
".page-picture",
|
||||
".page-section",
|
||||
".section-content",
|
||||
".page-section-profiles",
|
||||
".page-section-experience",
|
||||
".page-section-education",
|
||||
".page-section-projects",
|
||||
".page-section-skills",
|
||||
".page-section-languages",
|
||||
".page-section-interests",
|
||||
".page-section-awards",
|
||||
".page-section-certifications",
|
||||
".page-section-publications",
|
||||
".page-section-volunteer",
|
||||
".page-section-references",
|
||||
".page-section-custom",
|
||||
".section-item",
|
||||
".section-item-header",
|
||||
".section-item-title",
|
||||
".section-item-name",
|
||||
".section-item-description",
|
||||
".section-item-metadata",
|
||||
".section-item-link",
|
||||
".section-item-icon",
|
||||
".section-item-level",
|
||||
".section-item-keywords",
|
||||
".section-item-proficiency",
|
||||
".section-item-fluency",
|
||||
".section-item-location",
|
||||
".section-item-publisher",
|
||||
".section-item-issuer",
|
||||
".section-item-awarder",
|
||||
".profiles-item",
|
||||
".profiles-item-header",
|
||||
".profiles-item-title",
|
||||
".profiles-item-name",
|
||||
".profiles-item-description",
|
||||
".profiles-item-link",
|
||||
".profiles-item-icon",
|
||||
".profiles-item-network",
|
||||
".experience-item",
|
||||
".experience-item-header",
|
||||
".experience-item-title",
|
||||
".experience-item-name",
|
||||
".experience-item-description",
|
||||
".experience-item-link",
|
||||
".experience-item-metadata",
|
||||
".education-item",
|
||||
".education-item-header",
|
||||
".education-item-title",
|
||||
".education-item-name",
|
||||
".education-item-description",
|
||||
".education-item-link",
|
||||
".education-item-metadata",
|
||||
".projects-item",
|
||||
".projects-item-header",
|
||||
".projects-item-title",
|
||||
".projects-item-name",
|
||||
".projects-item-description",
|
||||
".projects-item-link",
|
||||
".skills-item",
|
||||
".skills-item-header",
|
||||
".skills-item-title",
|
||||
".skills-item-name",
|
||||
".skills-item-description",
|
||||
".skills-item-icon",
|
||||
".skills-item-level",
|
||||
".skills-item-keywords",
|
||||
".skills-item-proficiency",
|
||||
".languages-item",
|
||||
".languages-item-header",
|
||||
".languages-item-title",
|
||||
".languages-item-name",
|
||||
".languages-item-level",
|
||||
".languages-item-fluency",
|
||||
".interests-item",
|
||||
".interests-item-header",
|
||||
".interests-item-title",
|
||||
".interests-item-name",
|
||||
".interests-item-icon",
|
||||
".interests-item-keywords",
|
||||
".awards-item",
|
||||
".awards-item-header",
|
||||
".awards-item-title",
|
||||
".awards-item-name",
|
||||
".awards-item-description",
|
||||
".awards-item-link",
|
||||
".awards-item-awarder",
|
||||
".certifications-item",
|
||||
".certifications-item-header",
|
||||
".certifications-item-title",
|
||||
".certifications-item-name",
|
||||
".certifications-item-description",
|
||||
".certifications-item-link",
|
||||
".certifications-item-issuer",
|
||||
".publications-item",
|
||||
".publications-item-header",
|
||||
".publications-item-title",
|
||||
".publications-item-name",
|
||||
".publications-item-description",
|
||||
".publications-item-link",
|
||||
".publications-item-publisher",
|
||||
".volunteer-item",
|
||||
".volunteer-item-header",
|
||||
".volunteer-item-title",
|
||||
".volunteer-item-name",
|
||||
".volunteer-item-description",
|
||||
".volunteer-item-link",
|
||||
".volunteer-item-location",
|
||||
".references-item",
|
||||
".references-item-header",
|
||||
".references-item-title",
|
||||
".references-item-name",
|
||||
".references-item-description",
|
||||
".template-azurill",
|
||||
".template-bronzor",
|
||||
".template-chikorita",
|
||||
".template-ditto",
|
||||
".template-ditgar",
|
||||
".template-gengar",
|
||||
".template-glalie",
|
||||
".template-kakuna",
|
||||
".template-lapras",
|
||||
".template-leafish",
|
||||
".template-onyx",
|
||||
".template-pikachu",
|
||||
".template-rhyhorn",
|
||||
];
|
||||
|
||||
const CSS_CLASS_SELECTOR_PATTERN = /\.([\w-]*)$/;
|
||||
|
||||
type Props = {
|
||||
theme: Theme;
|
||||
defaultValue: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
};
|
||||
|
||||
export default function CSSMonacoEditor({ theme, defaultValue, onChange }: Props) {
|
||||
const monaco = useMonaco();
|
||||
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
const completionProvider = monaco.languages.registerCompletionItemProvider("css", {
|
||||
triggerCharacters: ["."],
|
||||
provideCompletionItems: (model, position) => {
|
||||
const textUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
const match = textUntilPosition.match(CSS_CLASS_SELECTOR_PATTERN);
|
||||
if (!match) return { suggestions: [] };
|
||||
|
||||
const prefix = match[1].toLowerCase();
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = CSS_SELECTORS.filter((selector) => selector.toLowerCase().startsWith(`.${prefix}`)).map(
|
||||
(selector) => ({
|
||||
label: selector,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: selector,
|
||||
range,
|
||||
detail: "Resume CSS Selector",
|
||||
documentation: `CSS selector for ${selector.replace(".", "")}`,
|
||||
}),
|
||||
);
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completionProvider.dispose();
|
||||
};
|
||||
}, [monaco]);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
language="css"
|
||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { Switch } from "@/components/animate-ui/components/radix/switch";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { useTheme } from "@/components/theme/provider";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { metadataSchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
const CSSMonacoEditor = lazy(() => import("./css-editor"));
|
||||
|
||||
export function CSSSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="css" className="pb-4">
|
||||
<CSSSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = metadataSchema.shape.css;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function CSSSectionForm() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const css = useResumeStore((state) => state.resume.data.metadata.css);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: css,
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.css = data;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="mt-2 -mb-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-4">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Trans context="Turn On/Apply Custom CSS">Enable</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("enabled") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem className="h-48 overflow-hidden rounded-md">
|
||||
<FormControl>
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<CSSMonacoEditor
|
||||
theme={theme}
|
||||
defaultValue={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value ?? "");
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { LevelTypeCombobox } from "@/components/level/combobox";
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { colorDesignSchema, levelDesignSchema } from "@/schema/resume/data";
|
||||
import { cn } from "@/utils/style";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function DesignSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="design" className="space-y-6">
|
||||
<ColorSectionForm />
|
||||
<Separator />
|
||||
<LevelSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSectionForm() {
|
||||
const colors = useResumeStore((state) => state.resume.data.metadata.design.colors);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<z.infer<typeof colorDesignSchema>>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(colorDesignSchema),
|
||||
defaultValues: colors,
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof colorDesignSchema>) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.colors = data;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primary"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-wrap gap-2.5 p-1">
|
||||
{quickColorOptions.map((color) => (
|
||||
<QuickColorCircle
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === field.value}
|
||||
onSelect={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primary"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Primary Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onValueChange={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
defaultValue={field.value}
|
||||
onValueChange={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="background"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
defaultValue={field.value}
|
||||
onValueChange={(color) => {
|
||||
field.onChange(color);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const quickColorOptions = [
|
||||
"rgba(231, 0, 11, 1)", // red-600
|
||||
"rgba(245, 73, 0, 1)", // orange-600
|
||||
"rgba(225, 113, 0, 1)", // amber-600
|
||||
"rgba(208, 135, 0, 1)", // yellow-600
|
||||
"rgba(94, 165, 0, 1)", // lime-600
|
||||
"rgba(0, 166, 62, 1)", // green-600
|
||||
"rgba(0, 153, 102, 1)", // emerald-600
|
||||
"rgba(0, 150, 137, 1)", // teal-600
|
||||
"rgba(0, 146, 184, 1)", // cyan-600
|
||||
"rgba(0, 132, 209, 1)", // sky-600
|
||||
"rgba(21, 93, 252, 1)", // blue-600
|
||||
"rgba(79, 57, 246, 1)", // indigo-600
|
||||
"rgba(127, 34, 254, 1)", // violet-600
|
||||
"rgba(152, 16, 250, 1)", // purple-600
|
||||
"rgba(200, 0, 222, 1)", // fuchsia-600
|
||||
"rgba(230, 0, 118, 1)", // pink-600
|
||||
"rgba(236, 0, 63, 1)", // rose-600
|
||||
"rgba(69, 85, 108, 1)", // slate-600
|
||||
"rgba(74, 85, 101, 1)", // gray-600
|
||||
"rgba(82, 82, 92, 1)", // zinc-600
|
||||
"rgba(82, 82, 82, 1)", // neutral-600
|
||||
"rgba(87, 83, 77, 1)", // stone-600
|
||||
];
|
||||
|
||||
type QuickColorCircleProps = React.ComponentProps<"button"> & {
|
||||
color: string;
|
||||
active: boolean;
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
function QuickColorCircle({ color, active, onSelect, className, ...props }: QuickColorCircleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
|
||||
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelSectionForm() {
|
||||
const colors = useResumeStore((state) => state.resume.data.metadata.design.colors);
|
||||
const levelDesign = useResumeStore((state) => state.resume.data.metadata.design.level);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<z.infer<typeof levelDesignSchema>>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(levelDesignSchema),
|
||||
defaultValues: levelDesign,
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof levelDesignSchema>) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.level = data;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<h4 className="font-semibold text-lg leading-none tracking-tight">
|
||||
<Trans>Level</Trans>
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
||||
className="flex items-center justify-center rounded-md p-6"
|
||||
>
|
||||
<LevelDisplay
|
||||
level={3}
|
||||
type={form.watch("type")}
|
||||
icon={form.watch("icon")}
|
||||
className="w-full max-w-[220px] justify-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormLabel>
|
||||
<Trans>Icon</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<IconPicker
|
||||
size="default"
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<LevelTypeCombobox
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resume = useResumeStore((state) => state.resume);
|
||||
|
||||
const { mutateAsync: printResumeAsPDF, isPending: isPrinting } = useMutation(
|
||||
orpc.printer.printResumeAsPDF.mutationOptions(),
|
||||
);
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
const filename = generateFilename(resume.data.basics.name, "json");
|
||||
const jsonString = JSON.stringify(resume, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
const filename = generateFilename(resume.data.basics.name, "pdf");
|
||||
const { url } = await printResumeAsPDF({ id: resume.id });
|
||||
|
||||
downloadFromUrl(url, filename);
|
||||
}, [resume, printResumeAsPDF]);
|
||||
|
||||
return (
|
||||
<SectionBase type="export" className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadJSON}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-left font-normal active:scale-98"
|
||||
>
|
||||
<FileJsIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">JSON</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
|
||||
other applications, including AI assistants.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-left font-normal active:scale-98"
|
||||
>
|
||||
{isPrinting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">PDF</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
|
||||
with recruiters.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function InformationSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="information" className="space-y-4">
|
||||
<div className="space-y-2 rounded-md border bg-sky-600 p-5 text-white dark:bg-sky-700">
|
||||
<h4 className="font-medium tracking-tight">
|
||||
<Trans>Support the app by doing what you can!</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 text-xs leading-normal">
|
||||
<Trans>
|
||||
<p>
|
||||
Thank you for using Reactive Resume! This app is a labor of love, created mostly in my spare time, with
|
||||
wonderful support from open-source contributors around the world.
|
||||
</p>
|
||||
<p>
|
||||
If Reactive Resume has been helpful to you, and you'd like to help keep it free and open for everyone,
|
||||
please consider making a donation. Every little bit is appreciated!
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<Button asChild size="sm" variant="default" className="mt-2 whitespace-normal px-4! text-xs">
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<HandHeartIcon />
|
||||
<span className="truncate">
|
||||
<Trans>Donate to Reactive Resume</Trans>
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<Button asChild size="sm" variant="link" className="text-xs">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Documentation</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="sm" variant="link" className="text-xs">
|
||||
<a href="https://github.com/AmruthPillai/Reactive-Resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Source Code</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="sm" variant="link" className="text-xs">
|
||||
<a href="https://github.com/AmruthPillai/Reactive-Resume/issues" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Report a Bug</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="sm" variant="link" className="text-xs">
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Translations</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="sm" variant="link" className="text-xs">
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Sponsors</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { metadataSchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../../shared/section-base";
|
||||
import { LayoutPages } from "./pages";
|
||||
|
||||
export function LayoutSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="layout" className="space-y-4">
|
||||
<LayoutPages />
|
||||
<LayoutSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = metadataSchema.shape.layout.omit({ pages: true });
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function LayoutSectionForm() {
|
||||
const sidebarWidth = useResumeStore((state) => state.resume.data.metadata.layout.sidebarWidth);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { sidebarWidth },
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.sidebarWidth = data.sidebarWidth;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sidebarWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Sidebar Width</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.01}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<InputGroup className="w-auto shrink-0">
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
type="number"
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>%</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
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 { type CSSProperties, forwardRef, type HTMLAttributes, useCallback, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Switch } from "@/components/animate-ui/components/radix/switch";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import type { SectionType } from "@/schema/resume/data";
|
||||
import { getSectionTitle } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () => t`Main`)
|
||||
.with("sidebar", () => t`Sidebar`)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
type PageLocation = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
* Format: "page-{index}-{columnId}" or "{sectionId}"
|
||||
*/
|
||||
const parseDroppableId = (id: string): PageLocation | null => {
|
||||
if (id.startsWith("page-")) {
|
||||
const parts = id.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const pageIndex = Number.parseInt(parts[1] ?? "0", 10);
|
||||
const columnId = parts[2] as ColumnId;
|
||||
if (!Number.isNaN(pageIndex) && (columnId === "main" || columnId === "sidebar")) {
|
||||
return { pageIndex, columnId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const createDroppableId = (pageIndex: number, columnId: ColumnId): string => {
|
||||
return `page-${pageIndex}-${columnId}`;
|
||||
};
|
||||
|
||||
export function LayoutPages() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const layout = useResumeStore((state) => state.resume.data.metadata.layout);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
*/
|
||||
const findContainer = useCallback(
|
||||
(id: string): PageLocation | null => {
|
||||
// Check if it's a droppable ID
|
||||
const location = parseDroppableId(id);
|
||||
if (location) return location;
|
||||
|
||||
// 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" };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[layout.pages],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => setActiveId(String(event.active.id)), []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = String(active.id);
|
||||
const overIdStr = String(over.id);
|
||||
|
||||
if (activeIdStr === overIdStr) return;
|
||||
|
||||
const activeLocation = findContainer(activeIdStr);
|
||||
const overLocation = parseDroppableId(overIdStr) ?? findContainer(overIdStr);
|
||||
|
||||
if (!activeLocation || !overLocation) return;
|
||||
|
||||
// Same location, reorder within column
|
||||
if (activeLocation.pageIndex === overLocation.pageIndex && activeLocation.columnId === overLocation.columnId) {
|
||||
const page = layout.pages[activeLocation.pageIndex];
|
||||
const items = page[activeLocation.columnId];
|
||||
const oldIdx = items.indexOf(activeIdStr);
|
||||
let newIdx = items.indexOf(overIdStr);
|
||||
if (oldIdx === -1 || oldIdx === newIdx) return;
|
||||
if (newIdx === -1) newIdx = items.length - 1;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const colOrder = draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId];
|
||||
draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId] = arrayMove(
|
||||
colOrder,
|
||||
oldIdx,
|
||||
newIdx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Different location, move between columns/pages
|
||||
const fromPage = layout.pages[activeLocation.pageIndex];
|
||||
const toPage = layout.pages[overLocation.pageIndex];
|
||||
const fromItems = fromPage[activeLocation.columnId];
|
||||
const toItems = toPage[overLocation.columnId];
|
||||
const fromIdx = fromItems.indexOf(activeIdStr);
|
||||
if (fromIdx === -1) return;
|
||||
|
||||
let toIdx = toItems.indexOf(overIdStr);
|
||||
if (toIdx === -1) toIdx = toItems.length;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const fromPageDraft = draft.metadata.layout.pages[activeLocation.pageIndex];
|
||||
const toPageDraft = draft.metadata.layout.pages[overLocation.pageIndex];
|
||||
const from = fromPageDraft[activeLocation.columnId];
|
||||
const to = toPageDraft[overLocation.columnId];
|
||||
|
||||
from.splice(fromIdx, 1);
|
||||
to.splice(Math.min(toIdx, to.length), 0, activeIdStr);
|
||||
});
|
||||
},
|
||||
[findContainer, layout.pages, updateResumeData],
|
||||
);
|
||||
|
||||
const handleAddPage = useCallback(() => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: [],
|
||||
sidebar: [],
|
||||
});
|
||||
});
|
||||
}, [updateResumeData]);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(pageIndex: number) => {
|
||||
if (layout.pages.length <= 1) return; // Don't allow deleting the last page
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const pageToDelete = draft.metadata.layout.pages[pageIndex];
|
||||
// Move all sections from deleted page to first page
|
||||
const firstPage = draft.metadata.layout.pages[0];
|
||||
firstPage.main.push(...pageToDelete.main);
|
||||
firstPage.sidebar.push(...pageToDelete.sidebar);
|
||||
|
||||
draft.metadata.layout.pages.splice(pageIndex, 1);
|
||||
});
|
||||
},
|
||||
[layout.pages.length, updateResumeData],
|
||||
);
|
||||
|
||||
const handleToggleFullWidth = useCallback(
|
||||
(pageIndex: number, fullWidth: boolean) => {
|
||||
updateResumeData((draft) => {
|
||||
const page = draft.metadata.layout.pages[pageIndex];
|
||||
page.fullWidth = fullWidth;
|
||||
|
||||
if (fullWidth) {
|
||||
// Move all sidebar sections to main
|
||||
page.main.push(...page.sidebar);
|
||||
page.sidebar = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateResumeData],
|
||||
);
|
||||
|
||||
// Don't render until pages are initialized
|
||||
if (layout.pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{layout.pages.map((page, pageIndex) => (
|
||||
<PageContainer
|
||||
key={pageIndex}
|
||||
pageIndex={pageIndex}
|
||||
page={page}
|
||||
canDelete={layout.pages.length > 1}
|
||||
onDelete={handleDeletePage}
|
||||
onToggleFullWidth={handleToggleFullWidth}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="self-end" onClick={handleAddPage}>
|
||||
<PlusIcon />
|
||||
<Trans>Add Page</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
|
||||
canDelete: boolean;
|
||||
onDelete: (pageIndex: number) => void;
|
||||
onToggleFullWidth: (pageIndex: number, fullWidth: boolean) => void;
|
||||
};
|
||||
|
||||
function PageContainer({ pageIndex, page, canDelete, onDelete, onToggleFullWidth }: PageContainerProps) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="font-medium text-xs">
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Switch checked={page.fullWidth} onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)} />
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans>Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
|
||||
<TrashIcon />
|
||||
<Trans>Delete Page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full gap-x-4 gap-y-2 p-4 pt-0 font-medium",
|
||||
page.fullWidth ? "grid-cols-1" : "@md:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<LayoutColumn pageIndex={pageIndex} columnId="main" items={page.main} disabled={false} />
|
||||
{!page.fullWidth && <LayoutColumn pageIndex={pageIndex} columnId="sidebar" items={page.sidebar} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutColumnProps = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
items: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function LayoutColumn({ pageIndex, columnId, items, disabled = false }: LayoutColumnProps) {
|
||||
const droppableId = createDroppableId(pageIndex, columnId);
|
||||
const { setNodeRef, isOver } = useDroppable({ id: droppableId, disabled });
|
||||
|
||||
return (
|
||||
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn(disabled && "opacity-50")}>
|
||||
<div className="@md:row-start-1 pl-4 font-medium text-xs">{getColumnLabel(columnId)}</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"space-y-2.5 rounded-md border border-dashed p-3 pb-8 transition-colors",
|
||||
isOver && !disabled ? "border-primary/60 bg-primary/5" : "bg-background/40",
|
||||
)}
|
||||
>
|
||||
{items.map((id) => (
|
||||
<SortableLayoutItem key={id} id={id} pageIndex={pageIndex} columnId={columnId} />
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 font-medium text-muted-foreground text-xs">
|
||||
<Trans>Drag and drop sections here to move them between columns</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
|
||||
type SortableLayoutItemProps = {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
};
|
||||
|
||||
const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
|
||||
({ id, isDragging, isOverlay, className, style, ...rest }, ref) => {
|
||||
const title = useResumeStore((state) => {
|
||||
if (id === "summary") return state.resume.data.summary.title || getSectionTitle("summary");
|
||||
if (id in state.resume.data.sections)
|
||||
return state.resume.data.sections[id as SectionType].title || getSectionTitle(id as SectionType);
|
||||
const customSection = state.resume.data.customSections.find((section) => section.id === id);
|
||||
if (customSection) return customSection.title;
|
||||
return 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/20 active:cursor-grabbing active:border-primary/60 active:bg-secondary/20",
|
||||
"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";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function NotesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="notes">
|
||||
<NotesSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSectionForm() {
|
||||
const notes = useResumeStore((state) => state.resume.data.metadata.notes);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.notes = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
<Trans>
|
||||
This section is reserved for your personal notes specific to this resume. The content here remains private and
|
||||
is not shared with anyone else.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<RichInput value={notes} onChange={onChange} />
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>
|
||||
For example, information regarding which companies you sent this resume to or the links to the job
|
||||
descriptions can be noted down here.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { Switch } from "@/components/animate-ui/components/radix/switch";
|
||||
import { getLocaleOptions } from "@/components/locale/combobox";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { pageSchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PageSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="page">
|
||||
<PageSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = pageSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function PageSectionForm() {
|
||||
const page = useResumeStore((state) => state.resume.data.metadata.page);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: page,
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.page = data;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={getLocaleOptions()}
|
||||
value={field.value}
|
||||
onValueChange={(locale) => {
|
||||
field.onChange(locale);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans context="Page Format (A4 or Letter)">Format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "a4", label: "A4" },
|
||||
{ value: "letter", label: "Letter" },
|
||||
]}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="marginX"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="marginY"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gapX"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gapY"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hideIcons"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full flex items-center gap-x-3 py-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>
|
||||
<Trans>Hide all icons on the resume</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Switch } from "@/components/animate-ui/components/radix/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SharingSectionBuilder() {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { data: session } = authClient.useSession();
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
|
||||
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
|
||||
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
|
||||
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
|
||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } }));
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session, resume]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onTogglePublic = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await updateResume({ id: resume.id, isPublic: checked });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[resume.id, updateResume],
|
||||
);
|
||||
|
||||
const onSetPassword = useCallback(async () => {
|
||||
const value = await prompt(t`Protect your resume from unauthorized access with a password`, {
|
||||
description: t`Anyone visiting the resume's public URL must enter this password to access it.`,
|
||||
confirmText: t`Set Password`,
|
||||
inputProps: {
|
||||
type: "password",
|
||||
minLength: 6,
|
||||
maxLength: 64,
|
||||
},
|
||||
});
|
||||
if (!value) return;
|
||||
|
||||
const password = value.trim();
|
||||
if (!password) return toast.error(t`Password cannot be empty.`);
|
||||
|
||||
const toastId = toast.loading(t`Enabling password protection...`);
|
||||
|
||||
try {
|
||||
await setPassword({ id: resume.id, password });
|
||||
toast.success(t`Password protection has been enabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [prompt, resume.id, setPassword]);
|
||||
|
||||
const onRemovePassword = useCallback(async () => {
|
||||
if (!resume.hasPassword) return;
|
||||
|
||||
const confirmation = await confirm(t`Are you sure you want to remove password protection?`, {
|
||||
description: t`Anyone who has the resume's public URL will be able to view and download your resume without entering a password.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Removing password protection...`);
|
||||
|
||||
try {
|
||||
await removePassword({ id: resume.id });
|
||||
toast.success(t`Password protection has been disabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [confirm, resume.id, resume.hasPassword, removePassword]);
|
||||
|
||||
const isPasswordProtected = resume.hasPassword;
|
||||
|
||||
return (
|
||||
<SectionBase type="sharing" className="space-y-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="sharing-switch"
|
||||
checked={resume.isPublic}
|
||||
onCheckedChange={(checked) => void onTogglePublic(checked)}
|
||||
/>
|
||||
|
||||
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
|
||||
<p className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</p>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>Anyone with the link can view and download the resume.</Trans>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{resume.isPublic && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sharing-url">URL</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
|
||||
<ClipboardIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{isPasswordProtected ? (
|
||||
<Trans>
|
||||
Your resume's public link is currently protected by a password. Share the password only with people you
|
||||
trust.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Optionally, set a password so that only people with the password can view your resume through the link.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isPasswordProtected ? (
|
||||
<Button variant="outline" onClick={onRemovePassword}>
|
||||
<LockSimpleOpenIcon />
|
||||
<Trans>Remove Password</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onSetPassword}>
|
||||
<LockSimpleIcon />
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@/components/animate-ui/components/radix/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function StatisticsSectionBuilder() {
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { data: statistics } = useQuery(
|
||||
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
|
||||
);
|
||||
|
||||
if (!statistics) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="statistics">
|
||||
<Accordion collapsible type="single" value={statistics.isPublic ? "isPublic" : "isPrivate"}>
|
||||
<AccordionItem value="isPrivate">
|
||||
<AccordionContent className="pb-0">
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Track your resume's views and downloads</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can
|
||||
see your resume's statistics.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="isPublic">
|
||||
<AccordionContent className="grid @md:grid-cols-2 grid-cols-1 gap-4 pb-0">
|
||||
<StatisticsItem
|
||||
label={t`Views`}
|
||||
value={statistics.views}
|
||||
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
|
||||
/>
|
||||
|
||||
<StatisticsItem
|
||||
label={t`Downloads`}
|
||||
value={statistics.downloads}
|
||||
timestamp={
|
||||
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
|
||||
}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type StatisticsItemProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mb-1 font-bold font-mono 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
<TemplateSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSectionForm() {
|
||||
const { i18n } = useLingui();
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
const template = useResumeStore((state) => state.resume.data.metadata.template);
|
||||
|
||||
const metadata = templates[template];
|
||||
|
||||
const onOpenTemplateGallery = () => {
|
||||
openDialog("resume.template.gallery", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
onClick={onOpenTemplateGallery}
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col space-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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{metadata.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeight } from "@/components/typography/combobox";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { typographySchema } from "@/schema/resume/data";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TypographySectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="typography">
|
||||
<TypographySectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = typographySchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function TypographySectionForm() {
|
||||
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: typography,
|
||||
});
|
||||
|
||||
const bodyFontFamily = form.watch("body.fontFamily");
|
||||
const headingFontFamily = form.watch("heading.fontFamily");
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.typography.body = data.body;
|
||||
draft.metadata.typography.heading = data.heading;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onChange={form.handleSubmit(onSubmit)} className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<p className="shrink-0 font-medium text-base">
|
||||
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body.fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<FontFamilyCombobox
|
||||
value={field.value}
|
||||
buttonProps={{ size: "lg", className: "text-base" }}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
field.onChange(value);
|
||||
const nextWeight = getNextWeight(value);
|
||||
if (nextWeight !== null) {
|
||||
form.setValue("body.fontWeights", [nextWeight], { shouldDirty: true });
|
||||
}
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body.fontWeights"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Font Weights</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<FontWeightCombobox
|
||||
value={field.value}
|
||||
fontFamily={bodyFontFamily}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body.fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body.lineHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<p className="shrink-0 font-medium text-base">
|
||||
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
|
||||
</p>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="heading.fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<FontFamilyCombobox
|
||||
value={field.value}
|
||||
buttonProps={{ size: "lg", className: "text-base" }}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
field.onChange(value);
|
||||
const nextWeight = getNextWeight(value);
|
||||
if (nextWeight !== null) {
|
||||
form.setValue("heading.fontWeights", [nextWeight], { shouldDirty: true });
|
||||
}
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="heading.fontWeights"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Font Weight</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<FontWeightCombobox
|
||||
value={field.value}
|
||||
fontFamily={headingFontFamily}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="heading.fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="heading.lineHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.onChange("");
|
||||
else field.onChange(Number(value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/animate-ui/components/radix/accordion";
|
||||
import { getSectionIcon, getSectionTitle, type RightSidebarSection } from "@/utils/resume/section";
|
||||
import { cn } from "@/utils/style";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
|
||||
type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
type: RightSidebarSection;
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
collapsible
|
||||
type="single"
|
||||
className="space-y-4"
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? "" : type}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger asChild className="mr-2 items-center justify-center">
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
</AccordionTrigger>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccordionContent
|
||||
className={cn(
|
||||
"overflow-hidden pb-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { leftSidebarSections, rightSidebarSections, type SidebarSection } from "@/utils/resume/section";
|
||||
|
||||
type SectionCollapseState = {
|
||||
[id in SidebarSection]?: { collapsed: boolean };
|
||||
};
|
||||
|
||||
type SectionStoreState = {
|
||||
sections: SectionCollapseState;
|
||||
};
|
||||
|
||||
type SectionStoreActions = {
|
||||
setCollapsed: (id: SidebarSection, collapsed: boolean) => void;
|
||||
toggleCollapsed: (id: SidebarSection) => void;
|
||||
toggleAll: () => void;
|
||||
};
|
||||
|
||||
type SectionStore = SectionStoreState & SectionStoreActions;
|
||||
|
||||
export const useSectionStore = create<SectionStore>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
sections: {},
|
||||
setCollapsed: (id, collapsed) => {
|
||||
set((state) => {
|
||||
state.sections[id] = { collapsed };
|
||||
});
|
||||
},
|
||||
toggleCollapsed: (id) => {
|
||||
set((state) => {
|
||||
const current = state.sections[id]?.collapsed ?? false;
|
||||
state.sections[id] = { collapsed: !current };
|
||||
});
|
||||
},
|
||||
toggleAll: () => {
|
||||
set((state) => {
|
||||
[...leftSidebarSections, ...rightSidebarSections].forEach((id) => {
|
||||
const current = state.sections[id]?.collapsed ?? false;
|
||||
state.sections[id] = { collapsed: !current };
|
||||
});
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "section-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
sections: state.sections,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { usePanelRef } from "react-resizable-panels";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { create } from "zustand/react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
interface BuilderSidebarState {
|
||||
leftSidebar: PanelImperativeHandle | null;
|
||||
rightSidebar: PanelImperativeHandle | null;
|
||||
}
|
||||
|
||||
interface BuilderSidebarActions {
|
||||
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
}
|
||||
|
||||
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
|
||||
|
||||
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
|
||||
isDragging: false,
|
||||
leftSidebar: null,
|
||||
rightSidebar: null,
|
||||
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
|
||||
setRightSidebar: (ref) => set({ rightSidebar: ref }),
|
||||
}));
|
||||
|
||||
type UseBuilderSidebarReturn = {
|
||||
maxSidebarSize: string | number;
|
||||
collapsedSidebarSize: number;
|
||||
isCollapsed: (side: "left" | "right") => boolean;
|
||||
toggleSidebar: (side: "left" | "right", forceState?: boolean) => void;
|
||||
};
|
||||
|
||||
export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (builder: UseBuilderSidebarReturn) => T): T {
|
||||
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 isCollapsed = useCallback((side: "left" | "right") => {
|
||||
const sidebar =
|
||||
side === "left"
|
||||
? useBuilderSidebarStore.getState().leftSidebar?.current
|
||||
: useBuilderSidebarStore.getState().rightSidebar?.current;
|
||||
|
||||
if (!sidebar) return false;
|
||||
return sidebar.isCollapsed();
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(
|
||||
(side: "left" | "right", forceState?: boolean) => {
|
||||
const sidebar =
|
||||
side === "left"
|
||||
? useBuilderSidebarStore.getState().leftSidebar?.current
|
||||
: useBuilderSidebarStore.getState().rightSidebar?.current;
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
const shouldExpand = forceState === undefined ? sidebar.isCollapsed() : forceState;
|
||||
|
||||
if (shouldExpand) sidebar.resize(expandSize);
|
||||
else sidebar.collapse();
|
||||
},
|
||||
[expandSize],
|
||||
);
|
||||
|
||||
const state = useMemo(() => {
|
||||
return {
|
||||
maxSidebarSize,
|
||||
collapsedSidebarSize,
|
||||
isCollapsed,
|
||||
toggleSidebar,
|
||||
};
|
||||
}, [maxSidebarSize, collapsedSidebarSize, isCollapsed, toggleSidebar]);
|
||||
|
||||
return selector ? selector(state) : (state as T);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { BuilderDock } from "./-components/dock";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="fixed inset-0">
|
||||
<TransformWrapper centerOnInit limitToBounds={false} minScale={0.3} initialScale={0.6} maxScale={6}>
|
||||
<TransformComponent wrapperClass="h-full! w-full!">
|
||||
<ResumePreview
|
||||
showPageNumbers
|
||||
className="flex items-start space-x-10 space-y-10"
|
||||
pageClassName="shadow-xl rounded-md overflow-hidden"
|
||||
/>
|
||||
</TransformComponent>
|
||||
|
||||
<BuilderDock />
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { type Layout, usePanelRef } from "react-resizable-panels";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { useCSSVariables } from "@/components/resume/hooks/use-css-variables";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@/components/ui/resizable";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
import { useBuilderSidebar, useBuilderSidebarStore } from "./-store/sidebar";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
|
||||
return { session: context.session };
|
||||
},
|
||||
loader: async ({ params, context }) => {
|
||||
const [layout, resume] = await Promise.all([
|
||||
getBuilderLayoutServerFn(),
|
||||
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
||||
]);
|
||||
|
||||
return { layout, name: resume.name };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: loaderData ? [{ title: `${loaderData.name} - Reactive Resume` }] : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { layout: initialLayout } = Route.useLoaderData();
|
||||
|
||||
const { resumeId } = Route.useParams();
|
||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
||||
|
||||
const style = useCSSVariables(resume.data);
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initialize = useResumeStore((state) => state.initialize);
|
||||
|
||||
useEffect(() => {
|
||||
initialize(resume);
|
||||
return () => initialize(null);
|
||||
}, [resume, initialize]);
|
||||
|
||||
if (!isReady) return <LoadingScreen />;
|
||||
|
||||
return <BuilderLayout style={style} initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
type BuilderLayoutProps = React.ComponentProps<"div"> & {
|
||||
initialLayout: Layout;
|
||||
};
|
||||
|
||||
function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
|
||||
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
|
||||
maxSidebarSize: state.maxSidebarSize,
|
||||
collapsedSidebarSize: state.collapsedSidebarSize,
|
||||
}));
|
||||
|
||||
const onLayoutChange = useDebounceCallback((layout: Layout) => {
|
||||
setBuilderLayoutServerFn({ data: layout });
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
|
||||
setLeftSidebar(leftSidebarRef);
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const leftSidebarSize = isMobile ? 0 : initialLayout.left;
|
||||
const rightSidebarSize = isMobile ? 0 : initialLayout.right;
|
||||
const artboardSize = isMobile ? 100 : initialLayout.artboard;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col" {...props}>
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" onLayoutChange={onLayoutChange} className="mt-14 flex-1">
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={collapsedSidebarSize * 2}
|
||||
collapsedSize={collapsedSidebarSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarLeft />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-20 border-r" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-20 border-l" />
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={collapsedSidebarSize * 2}
|
||||
collapsedSize={collapsedSidebarSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultLayout = { left: 30, artboard: 40, right: 30 };
|
||||
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
const layoutSchema = z.record(z.string(), z.number());
|
||||
|
||||
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(layoutSchema)
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data));
|
||||
});
|
||||
|
||||
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return defaultLayout;
|
||||
return layoutSchema.parse(JSON.parse(layout));
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import z from "zod";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
|
||||
export const getDashboardSidebarServerFn = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const sidebarState = getCookie(SIDEBAR_COOKIE_NAME) !== "false";
|
||||
return sidebarState;
|
||||
});
|
||||
|
||||
export const setDashboardSidebarServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator(z.boolean())
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(SIDEBAR_COOKIE_NAME, data.toString());
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Icon as IconType } from "@phosphor-icons/react";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
icon: IconType;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DashboardHeader({ title, icon: IconComponent, className }: Props) {
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center gap-x-2.5 md:justify-start", className)}>
|
||||
<SidebarTrigger className="absolute left-0 md:hidden" />
|
||||
<IconComponent weight="light" className="size-5" />
|
||||
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { MessageDescriptor } from "@lingui/core";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
WarningIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { BrandIcon } from "@/components/ui/brand-icon";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
useSidebarState,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
|
||||
import { getInitials } from "@/utils/string";
|
||||
|
||||
type SidebarItem = {
|
||||
icon: React.ReactNode;
|
||||
label: MessageDescriptor;
|
||||
href: React.ComponentProps<typeof Link>["to"];
|
||||
};
|
||||
|
||||
const appSidebarItems = [
|
||||
{
|
||||
icon: <ReadCvLogoIcon />,
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
] as const satisfies SidebarItem[];
|
||||
|
||||
const settingsSidebarItems = [
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
label: msg`Profile`,
|
||||
href: "/dashboard/settings/profile",
|
||||
},
|
||||
{
|
||||
icon: <GearSixIcon />,
|
||||
label: msg`Preferences`,
|
||||
href: "/dashboard/settings/preferences",
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
label: msg`Authentication`,
|
||||
href: "/dashboard/settings/authentication",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon />,
|
||||
label: msg`API Keys`,
|
||||
href: "/dashboard/settings/api-keys",
|
||||
},
|
||||
{
|
||||
icon: <BrainIcon />,
|
||||
label: msg`Artificial Intelligence`,
|
||||
href: "/dashboard/settings/ai",
|
||||
},
|
||||
{
|
||||
icon: <WarningIcon />,
|
||||
label: msg`Danger Zone`,
|
||||
href: "/dashboard/settings/danger-zone",
|
||||
},
|
||||
] as const satisfies SidebarItem[];
|
||||
|
||||
type SidebarItemListProps = {
|
||||
items: readonly SidebarItem[];
|
||||
};
|
||||
|
||||
function SidebarItemList({ items }: SidebarItemListProps) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild title={i18n.t(item.label)}>
|
||||
<Link to={item.href} activeProps={{ className: "bg-sidebar-accent" }}>
|
||||
{item.icon}
|
||||
<span className="shrink-0 transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ml-8 group-data-[collapsible=icon]:opacity-0">
|
||||
{i18n.t(item.label)}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebarState();
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className="h-auto justify-center">
|
||||
<Link to="/">
|
||||
<BrandIcon variant="icon" className="size-6" />
|
||||
<h1 className="sr-only">Reactive Resume</h1>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<Trans>App</Trans>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarItemList items={appSidebarItems} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<Trans>Settings</Trans>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarItemList items={settingsSidebarItems} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarFooter className="gap-y-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<UserDropdownMenu>
|
||||
{({ session }) => (
|
||||
<SidebarMenuButton className="h-auto gap-x-3 group-data-[collapsible=icon]:p-1!">
|
||||
<Avatar className="size-8 shrink-0 transition-all group-data-[collapsible=icon]:size-6">
|
||||
<AvatarImage src={session.user.image ?? undefined} />
|
||||
<AvatarFallback className="group-data-[collapsible=icon]:text-[0.5rem]">
|
||||
{getInitials(session.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ml-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<p className="font-medium">{session.user.name}</p>
|
||||
<p className="text-muted-foreground text-xs">{session.user.email}</p>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</UserDropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<AnimatePresence>
|
||||
{state === "expanded" && (
|
||||
<motion.div
|
||||
key="copyright"
|
||||
initial={{ y: 50, height: 0, opacity: 0 }}
|
||||
animate={{ y: 0, height: "auto", opacity: 1 }}
|
||||
exit={{ y: 50, height: 0, opacity: 0 }}
|
||||
>
|
||||
<Copyright className="shrink-0 text-nowrap p-2" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] }, replace: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type BaseCardProps = React.ComponentProps<"div"> & {
|
||||
title: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BaseCard({ title, description, tags, className, children, ...props }: BaseCardProps) {
|
||||
return (
|
||||
<CometCard translateDepth={3} rotateDepth={6}>
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative flex aspect-page size-full overflow-hidden rounded-md bg-popover shadow transition-shadow hover:shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
<h3 className="truncate font-medium tracking-tight">{title}</h3>
|
||||
<p className="truncate text-xs opacity-80">{description}</p>
|
||||
|
||||
<div className={cn("mt-2 hidden flex-wrap items-center gap-1", tags && tags.length > 0 && "flex")}>
|
||||
{tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CometCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export function CreateResumeCard() {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={t`Create a new resume`}
|
||||
description={t`Start building your resume from scratch`}
|
||||
onClick={() => openDialog("resume.create", undefined)}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<PlusIcon weight="thin" className="size-12" />
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export function ImportResumeCard() {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
title={t`Import an existing resume`}
|
||||
description={t`Continue where you left off`}
|
||||
onClick={() => openDialog("resume.import", undefined)}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<DownloadSimpleIcon weight="thin" className="size-12" />
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CircleNotchIcon,
|
||||
CopySimpleIcon,
|
||||
FolderOpenIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import { cn } from "@/utils/style";
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
type ResumeCardProps = React.ComponentProps<"div"> & {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
};
|
||||
|
||||
export function ResumeCard({ resume, ...props }: ResumeCardProps) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const { data: screenshotData, isLoading } = useQuery(
|
||||
orpc.printer.getResumeScreenshot.queryOptions({ input: { id: resume.id } }),
|
||||
);
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
const imageSrc = screenshotData?.url;
|
||||
|
||||
const updatedAt = useMemo(() => {
|
||||
return new Date(resume.updatedAt).toLocaleDateString();
|
||||
}, [resume.updatedAt]);
|
||||
|
||||
const handleUpdate = () => {
|
||||
openDialog("resume.update", resume);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
openDialog("resume.duplicate", resume);
|
||||
};
|
||||
|
||||
const handleToggleLock = async () => {
|
||||
if (!resume.isLocked) {
|
||||
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
|
||||
description: t`When locked, the resume cannot be updated or deleted.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
}
|
||||
|
||||
setLockedResume(
|
||||
{ id: resume.id, isLocked: !resume.isLocked },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
|
||||
description: t`This action cannot be undone.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your resume...`);
|
||||
|
||||
deleteResume(
|
||||
{ id: resume.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
{isLoading || !imageSrc ? (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<CircleNotchIcon weight="thin" className="size-12 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={resume.name}
|
||||
className={cn("size-full object-cover transition-all", resume.isLocked && "blur-xs")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
</Link>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem asChild>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<FolderOpenIcon />
|
||||
<Trans>Open</Trans>
|
||||
</Link>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem disabled={resume.isLocked} onSelect={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onSelect={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onSelect={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isLocked && (
|
||||
<motion.div
|
||||
key="resume-lock-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-popover p-6">
|
||||
<LockSimpleIcon weight="thin" className="size-12" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { ReadCvLogoIcon, SortAscendingIcon, TagIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import z from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { MultipleCombobox } from "@/components/ui/multiple-combobox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { cn } from "@/utils/style";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { CreateResumeCard } from "./-components/create-card";
|
||||
import { ImportResumeCard } from "./-components/import-card";
|
||||
import { ResumeCard } from "./-components/resume-card";
|
||||
|
||||
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
|
||||
|
||||
const searchSchema = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/dashboard/resumes/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ tags: [], sort: "lastUpdatedAt" })],
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { tags, sort } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const { data: allTags } = useQuery(orpc.resume.tags.list.queryOptions());
|
||||
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: { tags, sort } }));
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
if (!allTags) return [];
|
||||
return allTags.map((tag) => ({ value: tag, label: tag }));
|
||||
}, [allTags]);
|
||||
|
||||
const sortOptions = useMemo(() => {
|
||||
return [
|
||||
{ value: "lastUpdatedAt", label: i18n.t("Last Updated") },
|
||||
{ value: "createdAt", label: i18n.t("Created") },
|
||||
{ value: "name", label: i18n.t("Name") },
|
||||
];
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={ReadCvLogoIcon} title={t`Resumes`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center">
|
||||
<Combobox
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
navigate({ search: { tags, sort: value as SortOption } });
|
||||
}}
|
||||
buttonProps={{
|
||||
title: t`Sort by`,
|
||||
variant: "ghost",
|
||||
children: (_, option) => (
|
||||
<>
|
||||
<SortAscendingIcon />
|
||||
{option?.label}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<MultipleCombobox
|
||||
value={tags}
|
||||
options={tagOptions}
|
||||
onValueChange={(value) => {
|
||||
navigate({ search: { tags: value, sort } });
|
||||
}}
|
||||
buttonProps={{
|
||||
variant: "ghost",
|
||||
title: t`Filter by`,
|
||||
className: cn({ hidden: tagOptions.length === 0 }),
|
||||
children: (_, options) => (
|
||||
<>
|
||||
<TagIcon />
|
||||
{options.map((option) => (
|
||||
<Badge key={option.value} variant="outline">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -50 }}>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, y: -50, filter: "blur(12px)" }}
|
||||
transition={{ delay: (index + 2) * 0.05 }}
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { getDashboardSidebarServerFn, setDashboardSidebarServerFn } from "./-components/functions";
|
||||
import { DashboardSidebar } from "./-components/sidebar";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
|
||||
return { session: context.session };
|
||||
},
|
||||
loader: async () => {
|
||||
const sidebarState = await getDashboardSidebarServerFn();
|
||||
return { sidebarState };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const { sidebarState } = Route.useLoaderData();
|
||||
|
||||
const handleSidebarOpenChange = (open: boolean) => {
|
||||
setDashboardSidebarServerFn({ data: open }).then(() => {
|
||||
router.invalidate();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider open={sidebarState} onOpenChange={handleSidebarOpenChange}>
|
||||
<DashboardSidebar />
|
||||
|
||||
<main className="@container flex-1 p-4 md:pl-2">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BrainIcon, CheckCircleIcon, InfoIcon, XCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Switch } from "@/components/animate-ui/components/radix/switch";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { type AIProvider, useAIStore } from "@/integrations/ai/store";
|
||||
import { client } from "@/integrations/orpc/client";
|
||||
import { cn } from "@/utils/style";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const providerOptions: ComboboxOption<AIProvider>[] = [
|
||||
{ value: "vercel-ai-gateway", label: "Vercel AI Gateway", keywords: ["vercel", "gateway", "ai"] },
|
||||
{ value: "openai", label: "OpenAI", keywords: ["openai", "gpt", "chatgpt"] },
|
||||
{ value: "gemini", label: "Google Gemini", keywords: ["gemini", "google", "bard"] },
|
||||
{ value: "anthropic", label: "Anthropic Claude", keywords: ["anthropic", "claude", "ai"] },
|
||||
{ value: "ollama", label: "Ollama", keywords: ["ollama", "ai", "local"] },
|
||||
];
|
||||
|
||||
function AIForm() {
|
||||
const { set, model, apiKey, baseURL, provider, enabled, testStatus } = useAIStore();
|
||||
|
||||
const { mutate: testConnection, isPending: isTesting } = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (testStatus === "success") return;
|
||||
const stream = await client.ai.testConnection({ provider, model, apiKey, baseURL });
|
||||
let result = "";
|
||||
for await (const chunk of stream) {
|
||||
result += chunk;
|
||||
}
|
||||
set((draft) => {
|
||||
draft.testStatus = result === "1" ? "success" : "failure";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleProviderChange = (value: AIProvider | null) => {
|
||||
if (!value) return;
|
||||
set((draft) => {
|
||||
draft.provider = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.model = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.apiKey = value;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBaseURLChange = (value: string) => {
|
||||
set((draft) => {
|
||||
draft.baseURL = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="provider">
|
||||
<Trans>Provider</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
id="provider"
|
||||
value={provider}
|
||||
disabled={enabled}
|
||||
options={providerOptions}
|
||||
onValueChange={handleProviderChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Label htmlFor="model">
|
||||
<Trans>Model</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="model"
|
||||
type="text"
|
||||
value={model}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
placeholder="e.g., gpt-4, claude-3-opus, gemini-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="api-key">
|
||||
<Trans>API Key</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
disabled={enabled}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "ollama" && (
|
||||
<div className="flex flex-col gap-y-2 sm:col-span-2">
|
||||
<Label htmlFor="base-url">
|
||||
<Trans>Base URL (Optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
type="url"
|
||||
value={baseURL}
|
||||
disabled={enabled}
|
||||
placeholder="http://localhost:11434"
|
||||
onChange={(e) => handleBaseURLChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button variant="outline" disabled={isTesting || enabled} onClick={() => testConnection()}>
|
||||
{isTesting ? (
|
||||
<Spinner />
|
||||
) : testStatus === "success" ? (
|
||||
<CheckCircleIcon className="text-success" />
|
||||
) : testStatus === "failure" ? (
|
||||
<XCircleIcon className="text-destructive" />
|
||||
) : null}
|
||||
<Trans>Test Connection</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const isClient = useIsClient();
|
||||
|
||||
const enabled = useAIStore((state) => state.enabled);
|
||||
const canEnable = useAIStore((state) => state.canEnable());
|
||||
const setEnabled = useAIStore((state) => state.setEnabled);
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={BrainIcon} title={t`Artificial Intelligence`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<InfoIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>Your data is stored locally</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Everything entered here is stored locally on your browser. Your data is only sent to the server when
|
||||
making a request to the AI provider, and is never stored or logged on our servers.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-ai">
|
||||
<Trans>Enable AI Features</Trans>
|
||||
</Label>
|
||||
<Switch id="enable-ai" checked={enabled} disabled={!canEnable} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<p className={cn("flex items-center gap-x-2", enabled ? "text-success" : "text-destructive")}>
|
||||
{enabled ? <CheckCircleIcon /> : <XCircleIcon />}
|
||||
{enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
|
||||
</p>
|
||||
|
||||
<AIForm />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon, KeyIcon, LinkSimpleIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/api-keys")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
|
||||
const { data: apiKeys = [] } = useQuery({
|
||||
queryKey: ["auth", "api-keys"],
|
||||
queryFn: () => authClient.apiKey.list(),
|
||||
select: ({ data }) => {
|
||||
if (!data) return [];
|
||||
|
||||
return data
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.filter((key) => !!key.expiresAt && key.expiresAt.getTime() > Date.now());
|
||||
},
|
||||
});
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this API key?`, {
|
||||
description: t`The API key will no longer be able to access your data after deletion. This action cannot be undone.`,
|
||||
confirmText: t`Delete`,
|
||||
cancelText: t`Cancel`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your API key...`);
|
||||
|
||||
const { error } = await authClient.apiKey.delete({ keyId: id });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`The API key has been deleted successfully.`, { id: toastId });
|
||||
queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={KeyIcon} title={t`API Keys`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-sm border bg-popover p-6">
|
||||
<div className="rounded-sm bg-primary/10 p-2.5">
|
||||
<BookOpenIcon className="text-primary" size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-semibold">
|
||||
<Trans>How do I use the API?</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Explore the API documentation to learn how to integrate Reactive Resume with your applications. Find
|
||||
detailed endpoints, request examples, and authentication methods.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button asChild variant="link">
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full py-3"
|
||||
onClick={() => openDialog("api-key.create", undefined)}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new API key</Trans>
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
className="flex items-center gap-x-4 py-4"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ delay: index * 0.08 }}
|
||||
>
|
||||
<KeyIcon />
|
||||
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="font-mono text-xs">{key.start}...</p>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<Trans>Expires on {key.expiresAt?.toLocaleDateString()}</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { GithubLogoIcon, GoogleLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import type { AuthProvider } from "@/integrations/auth/types";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
|
||||
/**
|
||||
* Get the display name for a social provider
|
||||
*/
|
||||
export function getProviderName(providerId: AuthProvider): string {
|
||||
return match(providerId)
|
||||
.with("credential", () => "Password")
|
||||
.with("google", () => "Google")
|
||||
.with("github", () => "GitHub")
|
||||
.with("custom", () => "Custom OAuth")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon component for a social provider
|
||||
*/
|
||||
export function getProviderIcon(providerId: AuthProvider): ReactNode {
|
||||
return match(providerId)
|
||||
.with("credential", () => <PasswordIcon />)
|
||||
.with("google", () => <GoogleLogoIcon />)
|
||||
.with("github", () => <GithubLogoIcon />)
|
||||
.with("custom", () => <VaultIcon />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage authentication accounts
|
||||
*/
|
||||
export function useAuthAccounts() {
|
||||
const { data: accounts } = useQuery({
|
||||
queryKey: ["auth", "accounts"],
|
||||
queryFn: () => authClient.listAccounts(),
|
||||
select: ({ data }) => data ?? [],
|
||||
});
|
||||
|
||||
const getAccountByProviderId = useCallback(
|
||||
(providerId: string) => accounts?.find((account) => account.providerId === providerId),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const hasAccount = useCallback(
|
||||
(providerId: string) => !!getAccountByProviderId(providerId),
|
||||
[getAccountByProviderId],
|
||||
);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
hasAccount,
|
||||
getAccountByProviderId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage authentication provider linking/unlinking
|
||||
*/
|
||||
export function useAuthProviderActions() {
|
||||
const link = useCallback(async (provider: AuthProvider) => {
|
||||
const providerName = getProviderName(provider);
|
||||
const toastId = toast.loading(t`Linking your ${providerName} account...`);
|
||||
|
||||
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/settings/authentication" });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
}, []);
|
||||
|
||||
const unlink = useCallback(async (provider: AuthProvider, accountId: string) => {
|
||||
const providerName = getProviderName(provider);
|
||||
const toastId = toast.loading(t`Unlinking your ${providerName} account...`);
|
||||
|
||||
const { error } = await authClient.unlinkAccount({ providerId: provider, accountId });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
}, []);
|
||||
|
||||
return { link, unlink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get enabled social providers for the current user
|
||||
* Possible values: "credential", "google", "github", "custom"
|
||||
*/
|
||||
export function useEnabledProviders() {
|
||||
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
return { enabledProviders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to list the authenticated passkeys for the current user
|
||||
*/
|
||||
export function useAuthPasskeys() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["auth", "passkeys"],
|
||||
queryFn: () => authClient.passkey.listUserPasskeys(),
|
||||
select: ({ data }) => data ?? [],
|
||||
});
|
||||
|
||||
const passkeys = useMemo(() => data ?? [], [data]);
|
||||
|
||||
return { passkeys };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Passkey } from "@better-auth/passkey";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { useAuthPasskeys } from "./hooks";
|
||||
|
||||
export function PasskeysSection() {
|
||||
const prompt = usePrompt();
|
||||
const queryClient = useQueryClient();
|
||||
const { passkeys } = useAuthPasskeys();
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
const name = await prompt(t`What do you want to call this passkey?`);
|
||||
if (!name) return;
|
||||
|
||||
const toastId = toast.loading(t`Adding your passkey...`);
|
||||
|
||||
const { error } = await authClient.passkey.addPasskey({ name, useAutoRegister: true });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your passkey has been added successfully.`, { id: toastId });
|
||||
queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-x-3 font-medium text-base">
|
||||
<FingerprintIcon />
|
||||
<Trans>Passkeys</Trans>
|
||||
</h2>
|
||||
|
||||
<Button variant="outline" onClick={handleAddPasskey}>
|
||||
<PlusIcon />
|
||||
<Trans>Register New Device</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{passkeys.map((passkey) => (
|
||||
<PasskeyItem key={passkey.id} passkey={passkey} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type PasskeyItemProps = {
|
||||
passkey: Passkey;
|
||||
};
|
||||
|
||||
function PasskeyItem({ passkey }: PasskeyItemProps) {
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this passkey?`, {
|
||||
description: t`You cannot use the passkey "${passkey.name ?? "(WebAuthn Device)"}" anymore to sign in after deletion. This action cannot be undone.`,
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your passkey...`);
|
||||
|
||||
const { error } = await authClient.passkey.deletePasskey({ id: passkey.id });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your passkey has been deleted successfully.`, { id: toastId });
|
||||
queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={passkey.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-3 flex items-center"
|
||||
>
|
||||
<Button size="icon-sm" variant="ghost" className="shrink-0" onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
|
||||
<span className="mx-2 truncate text-nowrap border-r pr-2 font-medium">{passkey.name ?? "1Password"}</span>
|
||||
|
||||
<span className="flex-1 truncate text-nowrap text-muted-foreground text-xs">
|
||||
<Trans>Added on {passkey.createdAt.toLocaleDateString()}</Trans>
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useAuthAccounts } from "./hooks";
|
||||
|
||||
export function PasswordSection() {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { hasAccount } = useAuthAccounts();
|
||||
|
||||
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
|
||||
|
||||
const handleUpdatePassword = useCallback(() => {
|
||||
if (hasPassword) {
|
||||
openDialog("auth.change-password", undefined);
|
||||
} else {
|
||||
navigate({ to: "/auth/forgot-password" });
|
||||
}
|
||||
}, [hasPassword, navigate, openDialog]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<h2 className="flex items-center gap-x-3 font-medium text-base">
|
||||
<PasswordIcon />
|
||||
<Trans>Password</Trans>
|
||||
</h2>
|
||||
|
||||
{match(hasPassword)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUpdatePassword}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans>Set Password</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.exhaustive()}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { AuthProvider } from "@/integrations/auth/types";
|
||||
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
|
||||
|
||||
type SocialProviderSectionProps = {
|
||||
provider: AuthProvider;
|
||||
name?: string;
|
||||
animationDelay?: number;
|
||||
};
|
||||
|
||||
export function SocialProviderSection({ provider, name, animationDelay = 0 }: SocialProviderSectionProps) {
|
||||
const { link, unlink } = useAuthProviderActions();
|
||||
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
|
||||
|
||||
const providerName = useMemo(() => name ?? getProviderName(provider), [name, provider]);
|
||||
const providerIcon = useMemo(() => getProviderIcon(provider), [provider]);
|
||||
|
||||
const account = useMemo(() => getAccountByProviderId(provider), [getAccountByProviderId, provider]);
|
||||
const isConnected = useMemo(() => hasAccount(provider), [hasAccount, provider]);
|
||||
|
||||
const handleLink = useCallback(() => {
|
||||
link(provider);
|
||||
}, [link, provider]);
|
||||
|
||||
const handleUnlink = useCallback(() => {
|
||||
if (!account?.accountId) return;
|
||||
unlink(provider, account.accountId);
|
||||
}, [account, unlink, provider]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: animationDelay }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-x-3 font-medium text-base">
|
||||
{providerIcon}
|
||||
{providerName}
|
||||
</h2>
|
||||
|
||||
{match(isConnected)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleUnlink}>
|
||||
<LinkBreakIcon />
|
||||
<Trans>Disconnect</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleLink}>
|
||||
<LinkIcon />
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { useAuthAccounts } from "./hooks";
|
||||
|
||||
export function TwoFactorSection() {
|
||||
const { openDialog } = useDialogStore();
|
||||
const { hasAccount } = useAuthAccounts();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
|
||||
const hasTwoFactor = useMemo(() => session?.user.twoFactorEnabled ?? false, [session]);
|
||||
|
||||
const handleTwoFactorAction = useCallback(() => {
|
||||
if (hasTwoFactor) {
|
||||
openDialog("auth.two-factor.disable", undefined);
|
||||
} else {
|
||||
openDialog("auth.two-factor.enable", undefined);
|
||||
}
|
||||
}, [hasTwoFactor, openDialog]);
|
||||
|
||||
if (!hasPassword) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-x-3 font-medium text-base">
|
||||
{hasTwoFactor ? <LockOpenIcon /> : <KeyIcon />}
|
||||
<Trans>Two-Factor Authentication</Trans>
|
||||
</h2>
|
||||
|
||||
{match(hasTwoFactor)
|
||||
.with(true, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleLeftIcon />
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
))
|
||||
.with(false, () => (
|
||||
<Button variant="outline" onClick={handleTwoFactorAction}>
|
||||
<ToggleRightIcon />
|
||||
<Trans>Enable 2FA</Trans>
|
||||
</Button>
|
||||
))
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ShieldCheckIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DashboardHeader } from "../../-components/header";
|
||||
import { useEnabledProviders } from "./-components/hooks";
|
||||
import { PasskeysSection } from "./-components/passkeys";
|
||||
import { PasswordSection } from "./-components/password";
|
||||
import { SocialProviderSection } from "./-components/social-provider";
|
||||
import { TwoFactorSection } from "./-components/two-factor";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/authentication/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { enabledProviders } = useEnabledProviders();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={ShieldCheckIcon} title={t`Authentication`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-4"
|
||||
>
|
||||
<PasswordSection />
|
||||
|
||||
<TwoFactorSection />
|
||||
|
||||
<PasskeysSection />
|
||||
|
||||
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
|
||||
|
||||
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
|
||||
|
||||
{"custom" in enabledProviders && (
|
||||
<SocialProviderSection provider="custom" animationDelay={0.6} name={enabledProviders.custom} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TrashSimpleIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { orpc } from "@/integrations/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/danger-zone")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const CONFIRMATION_TEXT = "delete";
|
||||
|
||||
function RouteComponent() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const isConfirmationValid = confirmationText === CONFIRMATION_TEXT;
|
||||
|
||||
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
|
||||
description: t`This action cannot be undone. All your data will be permanently deleted.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your account...`);
|
||||
|
||||
deleteAccount(undefined, {
|
||||
onSuccess: async () => {
|
||||
toast.success(t`Your account has been deleted successfully.`, { id: toastId });
|
||||
await authClient.signOut();
|
||||
navigate({ to: "/" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={WarningIcon} title={t`Danger Zone`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<p className="leading-relaxed">
|
||||
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
|
||||
</p>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="justify-self-end"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={!isConfirmationValid}
|
||||
>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete Account</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, GearSixIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { LocaleCombobox } from "@/components/locale/combobox";
|
||||
import { ThemeCombobox } from "@/components/theme/combobox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/preferences")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={GearSixIcon} title={t`Preferences`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
<Trans>Theme</Trans>
|
||||
</Label>
|
||||
<ThemeCombobox />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="mb-0.5">
|
||||
<Trans>Language</Trans>
|
||||
</Label>
|
||||
<LocaleCombobox />
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="h-5 justify-start text-muted-foreground text-xs active:scale-100"
|
||||
>
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Help translate the app to your language</Trans>
|
||||
<ArrowRightIcon className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CheckIcon, UserCircleIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import z from "zod";
|
||||
import { Button } from "@/components/animate-ui/components/buttons/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authClient } from "@/integrations/auth/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/profile")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9._-]+$/, {
|
||||
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
|
||||
}),
|
||||
email: z.email().trim(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const { session } = Route.useRouteContext();
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
return {
|
||||
name: session.user.name,
|
||||
username: session.user.username,
|
||||
email: session.user.email,
|
||||
};
|
||||
}, [session.user]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onCancel = () => {
|
||||
form.reset(defaultValues);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
name: data.name,
|
||||
username: data.username,
|
||||
displayUsername: data.username,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your profile has been updated successfully.`);
|
||||
form.reset({ name: data.name, username: data.username, email: session.user.email });
|
||||
router.invalidate();
|
||||
|
||||
if (data.email !== session.user.email) {
|
||||
const { error } = await authClient.changeEmail({
|
||||
newEmail: data.email,
|
||||
callbackURL: "/dashboard/settings/profile",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t`A confirmation link has been sent to your current email address. Please check your inbox to confirm the change.`,
|
||||
);
|
||||
form.reset({ name: data.name, username: data.username, email: session.user.email });
|
||||
router.invalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerificationEmail = async () => {
|
||||
const toastId = toast.loading(t`Resending verification email...`);
|
||||
|
||||
const { error } = await authClient.sendVerificationEmail({
|
||||
email: session.user.email,
|
||||
callbackURL: "/dashboard/settings/profile",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t`A new verification link has been sent to your email address. Please check your inbox to verify your account.`,
|
||||
{ id: toastId },
|
||||
);
|
||||
router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DashboardHeader icon={UserCircleIcon} title={t`Profile`} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<Form {...form}>
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="grid max-w-xl gap-6"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input min={3} max={64} autoComplete="name" placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="username"
|
||||
placeholder="john.doe"
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="john.doe@example.com"
|
||||
className="lowercase"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{match(session.user.emailVerified)
|
||||
.with(true, () => (
|
||||
<p className="flex items-center gap-x-1.5 text-green-700 text-xs">
|
||||
<CheckIcon />
|
||||
<Trans>Verified</Trans>
|
||||
</p>
|
||||
))
|
||||
.with(false, () => (
|
||||
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
|
||||
<WarningIcon className="size-3.5" />
|
||||
<Trans>Unverified</Trans>
|
||||
<span>|</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto gap-x-1.5 p-0! text-inherit text-xs"
|
||||
onClick={handleResendVerificationEmail}
|
||||
>
|
||||
<Trans>Resend verification email</Trans>
|
||||
</Button>
|
||||
</p>
|
||||
))
|
||||
.exhaustive()}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="flex items-center gap-x-4 justify-self-end"
|
||||
>
|
||||
<Button type="reset" variant="ghost" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { useResumeStore } from "@/components/resume/store/resume";
|
||||
import { getORPCClient } from "@/integrations/orpc/client";
|
||||
import { env } from "@/utils/env";
|
||||
import { verifyPrinterToken } from "@/utils/printer-token";
|
||||
|
||||
const searchSchema = z.object({
|
||||
token: z.string().catch(""),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/printer/$resumeId")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
beforeLoad: async ({ params, search }) => {
|
||||
if (env.FLAG_DEBUG_PRINTER) return;
|
||||
|
||||
try {
|
||||
// Verify the token and ensure it matches the resume ID
|
||||
const tokenResumeId = verifyPrinterToken(search.token);
|
||||
if (tokenResumeId !== params.resumeId) throw new Error();
|
||||
} catch {
|
||||
// Invalid or missing token - throw error to be caught by error handler
|
||||
throw redirect({ to: "/", search: {}, throw: true });
|
||||
}
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
const client = getORPCClient();
|
||||
const resume = await client.resume.getByIdForPrinter({ id: params.resumeId });
|
||||
|
||||
return { resume };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { resume } = Route.useLoaderData();
|
||||
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initialize = useResumeStore((state) => state.initialize);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resume) return;
|
||||
initialize(resume);
|
||||
return () => initialize(null);
|
||||
}, [resume, initialize]);
|
||||
|
||||
if (!isReady) return <LoadingScreen />;
|
||||
|
||||
return <ResumePreview pageClassName="print:w-full!" />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { json } from "@tanstack/react-start";
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@/schema/resume/data";
|
||||
|
||||
function handler({ request }: { request: Request }) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const resumeDataJSONSchema = z.toJSONSchema(
|
||||
resumeDataSchema.extend({
|
||||
version: z.literal("5.0.0").describe("The version of the Reactive Resume JSON Schema"),
|
||||
$schema: z
|
||||
.literal(`${url.origin}/schema.json`)
|
||||
.describe("The URL of the Reactive Resume JSON Schema, used for validation and documentation purposes."),
|
||||
}),
|
||||
);
|
||||
|
||||
return json(resumeDataJSONSchema, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: `"v5.0.0"`,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/schema.json")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getStorageService, inferContentType } from "@/integrations/orpc/services/storage";
|
||||
import { env } from "@/utils/env";
|
||||
|
||||
const storageService = getStorageService();
|
||||
|
||||
export const Route = createFileRoute("/uploads/$userId/$")({
|
||||
server: { handlers: { GET: handler } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Handler for GET requests to serve uploaded files, supporting ETags, content security, and path validation.
|
||||
* Handles nested paths like:
|
||||
* - /uploads/{userId}/pictures/{timestamp}.webp
|
||||
* - /uploads/{userId}/screenshots/{resumeId}/{timestamp}.webp
|
||||
* - /uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf
|
||||
*/
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
|
||||
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
||||
|
||||
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
||||
|
||||
// Build the full storage key: uploads/{userId}/{filePath}
|
||||
const key = `uploads/${userId}/${filePath}`;
|
||||
const storedFile = await storageService.read(key);
|
||||
|
||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const headers = buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
|
||||
const buffer = toArrayBuffer(storedFile.data);
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts userId and the remaining file path from the request URL.
|
||||
*/
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
const pathname = new URL(url).pathname;
|
||||
const pathAfterUploads = pathname.replace("/uploads/", "");
|
||||
const firstSlashIndex = pathAfterUploads.indexOf("/");
|
||||
|
||||
if (firstSlashIndex === -1) {
|
||||
return { userId: pathAfterUploads, filePath: undefined };
|
||||
}
|
||||
|
||||
const userId = pathAfterUploads.slice(0, firstSlashIndex);
|
||||
const filePath = pathAfterUploads.slice(firstSlashIndex + 1);
|
||||
|
||||
return { userId, filePath: filePath || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path segment does not contain directory traversal attempts.
|
||||
*/
|
||||
function isValidPath(segment: string): boolean {
|
||||
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
|
||||
return normalized === segment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all segments in a path for directory traversal attempts.
|
||||
*/
|
||||
function isValidPathSegments(path: string): boolean {
|
||||
const segments = path.split("/");
|
||||
|
||||
return segments.every((segment) => isValidPath(segment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for ETag match for conditional GET requests.
|
||||
*/
|
||||
function isNotModified(headers: Headers, etag: string): boolean {
|
||||
const ifNoneMatch = headers.get("If-None-Match");
|
||||
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
||||
|
||||
return candidates.includes(etag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 304 Not Modified response with caching headers.
|
||||
*/
|
||||
function makeNotModifiedResponse(etag: string): Response {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: { ETag: etag, "Cache-Control": "public, max-age=31536000, immutable" },
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds all headers for serving the file, including caching, security, and download headers.
|
||||
*/
|
||||
function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): Headers {
|
||||
const headers = new Headers();
|
||||
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
|
||||
// Security Headers
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox;");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array to ArrayBuffer efficiently.
|
||||
*/
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
: (data.slice().buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates or returns the ETag for a stored file.
|
||||
*/
|
||||
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
||||
if (storedFile.etag) {
|
||||
const tag = storedFile.etag.trim();
|
||||
|
||||
if (tag.startsWith("W/") || tag.startsWith('"')) {
|
||||
return tag;
|
||||
}
|
||||
return `"${tag}"`;
|
||||
}
|
||||
|
||||
const hash = createHash("sha256").update(storedFile.data).digest("hex");
|
||||
|
||||
return `"${storedFile.size}-${hash}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user