mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 01:44:53 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { createFileRoute, lazyRouteComponent, notFound, redirect } from "@tanstack/react-router";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { data: ResumeData };
|
||||
|
||||
export const Route = createFileRoute("/$username/$slug")({
|
||||
component: lazyRouteComponent(() => import("./-components/public-resume"), "PublicResumeRoute"),
|
||||
loader: async ({ context, params }) => {
|
||||
const { username, slug } = params;
|
||||
const resume = await context.queryClient.ensureQueryData(
|
||||
orpc.resume.getBySlug.queryOptions({ input: { username, slug } }),
|
||||
);
|
||||
|
||||
return { resume: resume as LoaderData };
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
const resume = loaderData?.resume;
|
||||
const title = resume ? resume.name || resume.data.basics.name || "Resume" : "Reactive Resume";
|
||||
return { meta: [{ title: `${title} - 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();
|
||||
},
|
||||
ssr: "data-only",
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
|
||||
const publicResumeRoute = getRouteApi("/$username/$slug");
|
||||
|
||||
export function PublicResumeRoute() {
|
||||
const { username, slug } = publicResumeRoute.useParams();
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name || resume.data.basics.name || resume.slug, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
if (!resume) return <LoadingScreen />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto my-12 flex flex-col items-center gap-12 print:m-0 print:block print:max-w-full print:px-0">
|
||||
<ResumePreview
|
||||
pageGap="1rem"
|
||||
pageScale={1.25}
|
||||
pageLayout="vertical"
|
||||
pageClassName="print:w-full! w-full max-w-full"
|
||||
/>
|
||||
|
||||
<footer className="flex justify-center print:hidden">
|
||||
<BrandIcon variant="icon" className="size-8 opacity-60" />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
onClick={onDownloadPDF}
|
||||
aria-label={t`Download PDF`}
|
||||
title={t`Download PDF`}
|
||||
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-opacity hover:opacity-100 print:hidden"
|
||||
>
|
||||
{isPrinting ? <CircleNotchIcon className="size-5 animate-spin" /> : <DownloadSimpleIcon className="size-5" />}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const okResponse = () => new Response("OK", { status: 200 });
|
||||
|
||||
export const Route = createFileRoute("/.well-known/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => okResponse(),
|
||||
HEAD: () => okResponse(),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { buildMcpServerCard } from "@/routes/mcp/-helpers/mcp-server-card";
|
||||
|
||||
/** Well-known MCP server card (SEP-1649) for static metadata when clients cannot complete a full capability scan. */
|
||||
export const Route = createFileRoute("/.well-known/mcp/server-card.json")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () =>
|
||||
Response.json(buildMcpServerCard(__APP_VERSION__), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
|
||||
const handler = oauthProviderAuthServerMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const metadata = await authClient.getProtectedResourceMetadata({
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
});
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
|
||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const metadata = await authClient.getProtectedResourceMetadata({
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
});
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
|
||||
const handler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: ({ request }) => handler(request),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { IconProps } from "@phosphor-icons/react";
|
||||
import type { FeatureFlags } from "@reactive-resume/api/services/flags";
|
||||
import type { AuthSession } from "@reactive-resume/auth/types";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { Locale } from "@/libs/locale";
|
||||
import type { orpc } from "@/libs/orpc/client";
|
||||
import type { Theme } from "@/libs/theme";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
import { HotkeysProvider } from "@tanstack/react-hotkeys";
|
||||
import { createRootRouteWithContext, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { DirectionProvider } from "@reactive-resume/ui/components/direction";
|
||||
import { Toaster } from "@reactive-resume/ui/components/sonner";
|
||||
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
|
||||
import { ThemeProvider } from "@/components/theme/provider";
|
||||
import { DialogManager } from "@/dialogs/manager";
|
||||
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
|
||||
import { PromptDialogProvider } from "@/hooks/use-prompt";
|
||||
import { getSession } from "@/libs/auth/session";
|
||||
import { getLocale, isRTL, loadLocale } from "@/libs/locale";
|
||||
import { client } from "@/libs/orpc/client";
|
||||
import { pwaHeadMetaTags, pwaServiceWorkerRegistrationScript } from "@/libs/pwa";
|
||||
import { getTheme } from "@/libs/theme";
|
||||
import appCss from "../index.css?url";
|
||||
|
||||
type RouterContext = {
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
orpc: typeof orpc;
|
||||
queryClient: QueryClient;
|
||||
session: AuthSession | null;
|
||||
flags: FeatureFlags;
|
||||
};
|
||||
|
||||
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.";
|
||||
|
||||
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.ico", type: "image/x-icon", sizes: "128x128" },
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml", sizes: "256x256 any" },
|
||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon-180x180.png", type: "image/png", sizes: "180x180 any" },
|
||||
// Manifest
|
||||
{ rel: "manifest", href: "/manifest.webmanifest", crossOrigin: "use-credentials" },
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ charSet: "UTF-8" },
|
||||
{ name: "description", content: description },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
...pwaHeadMetaTags,
|
||||
// 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 },
|
||||
],
|
||||
scripts: import.meta.env.PROD ? [{ children: pwaServiceWorkerRegistrationScript }] : [],
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const [theme, locale, session, flags] = await Promise.all([
|
||||
getTheme(),
|
||||
getLocale(),
|
||||
getSession(),
|
||||
client.flags.get(),
|
||||
]);
|
||||
|
||||
await loadLocale(locale);
|
||||
|
||||
return { theme, locale, session, flags };
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function RootDocument({ children }: Props) {
|
||||
const { theme, locale } = Route.useRouteContext();
|
||||
const dir = isRTL(locale) ? "rtl" : "ltr";
|
||||
|
||||
const iconContextValue = useMemo<IconProps>(() => ({ size: 16, weight: "regular" }), []);
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning dir={dir} lang={locale} className={theme}>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<I18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={iconContextValue}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<HotkeysProvider>
|
||||
<DirectionProvider>
|
||||
<TooltipProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<PromptDialogProvider>
|
||||
{children}
|
||||
|
||||
<DialogManager />
|
||||
<CommandPalette />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
|
||||
{import.meta.env.DEV && <BreakpointIndicator />}
|
||||
</PromptDialogProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</TooltipProvider>
|
||||
</DirectionProvider>
|
||||
</HotkeysProvider>
|
||||
</ThemeProvider>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
</MotionConfig>
|
||||
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { IconProps } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, HeartIcon, RocketIcon, SparkleIcon, UsersIcon, WrenchIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type FloatingIconProps = {
|
||||
icon: React.ElementType;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
const FloatingIcon = ({ icon: Icon, className, delay = 0 }: FloatingIconProps) => (
|
||||
<motion.div
|
||||
className={cn("absolute text-primary/20", className)}
|
||||
animate={{
|
||||
y: [0, -12, 0],
|
||||
rotate: [0, 5, -5, 0],
|
||||
scale: [1, 1.1, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
delay,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<Icon size={32} weight="duotone" />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const PulsingHeart = () => (
|
||||
<motion.div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
animate={{
|
||||
scale: [1, 1.8],
|
||||
opacity: [0.6, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<HeartIcon size={48} weight="fill" className="text-rose-500" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
type SparkleEffectProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SparkleEffect = ({ className }: SparkleEffectProps) => (
|
||||
<motion.div
|
||||
className={cn("absolute", className)}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
opacity: [0, 1, 0],
|
||||
rotate: [0, 180],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<SparkleIcon size={16} weight="fill" className="text-amber-400" />
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
type FeatureCardProps = {
|
||||
icon: React.ElementType<IconProps>;
|
||||
title: string;
|
||||
description: string;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
const FeatureCard = ({ icon: Icon, title, description, delay }: FeatureCardProps) => (
|
||||
<motion.div
|
||||
className="group relative flex flex-col items-center gap-3 rounded-2xl border border-border/50 bg-card/50 p-6 text-center backdrop-blur-sm transition-colors hover:border-primary/30 hover:bg-card/80"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay }}
|
||||
whileHover={{ y: -4 }}
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="flex size-12 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors group-hover:bg-primary/20"
|
||||
whileHover={{ rotate: [0, -10, 10, 0] }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Icon size={24} weight="light" />
|
||||
</motion.div>
|
||||
<h3 className="font-semibold tracking-tight">{title}</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{description}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
export const DonationBanner = () => (
|
||||
<section className="relative overflow-hidden bg-linear-to-b from-background via-primary/2 to-background py-24">
|
||||
{/* Background decorative elements */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<FloatingIcon icon={HeartIcon} className="top-[20%] left-[10%]" delay={0} />
|
||||
<FloatingIcon icon={SparkleIcon} className="top-[15%] right-[15%]" delay={0.5} />
|
||||
<FloatingIcon icon={UsersIcon} className="bottom-[25%] left-[8%]" delay={1} />
|
||||
<FloatingIcon icon={WrenchIcon} className="right-[12%] bottom-[30%]" delay={1.5} />
|
||||
<FloatingIcon icon={RocketIcon} className="top-[35%] right-[25%]" delay={2} />
|
||||
<FloatingIcon icon={HeartIcon} className="bottom-[20%] left-[20%]" delay={2.5} />
|
||||
|
||||
{/* Gradient Orbs */}
|
||||
<div className="absolute -inset-s-32 top-1/4 size-64 rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="absolute -inset-e-32 bottom-1/4 size-64 rounded-full bg-rose-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="container relative px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<div aria-hidden="true" className="relative mb-6">
|
||||
<PulsingHeart />
|
||||
<SparkleEffect className="-inset-e-4 -top-2" />
|
||||
<SparkleEffect className="-inset-s-3 bottom-0" />
|
||||
</div>
|
||||
|
||||
<motion.h2
|
||||
className="mb-6 font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Trans>Support Reactive Resume</Trans>
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
className="max-w-3xl text-base text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source project, built with love and maintained by me and a community of
|
||||
contributors. Your donations help keep the lights on and the code flowing.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Feature cards */}
|
||||
<div className="mx-auto my-12 grid max-w-5xl gap-8 sm:grid-cols-3">
|
||||
<FeatureCard
|
||||
icon={RocketIcon}
|
||||
title={t`Long-term Sustainability`}
|
||||
description={t`Your support ensures the project remains free and accessible for everyone, now and in the future.`}
|
||||
delay={0.3}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={WrenchIcon}
|
||||
title={t`Ongoing Maintenance`}
|
||||
description={t`Contributions fund bug fixes, security updates, and continuous improvements to keep the app running smoothly.`}
|
||||
delay={0.4}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={UsersIcon}
|
||||
title={t`Grow the Team`}
|
||||
description={t`Help me bring more experienced contributors on board, reducing the burden on a single maintainer and accelerating development.`}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center gap-4 sm:flex-row"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
nativeButton={false}
|
||||
className="h-11 gap-2 px-6"
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
|
||||
Open Collective
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
nativeButton={false}
|
||||
className="h-11 gap-2 px-6"
|
||||
render={
|
||||
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener">
|
||||
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
|
||||
GitHub Sponsors
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer note */}
|
||||
<motion.p
|
||||
className="mt-8 text-center text-muted-foreground leading-relaxed"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
>
|
||||
<Trans>
|
||||
Every contribution, big or small, makes a huge difference to the project.
|
||||
<br />
|
||||
Thank you for your support!
|
||||
</Trans>
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const crowdinUrl = "https://crowdin.com/project/reactive-resume";
|
||||
|
||||
type FAQItemData = {
|
||||
question: string;
|
||||
answer: React.ReactNode;
|
||||
};
|
||||
|
||||
const getFaqItems = (): FAQItemData[] => [
|
||||
{
|
||||
question: t`Is Reactive Resume really free?`,
|
||||
answer: t`Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.`,
|
||||
},
|
||||
{
|
||||
question: t`How is my data protected?`,
|
||||
answer: t`Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.`,
|
||||
},
|
||||
{
|
||||
question: t`Can I export my resume to PDF?`,
|
||||
answer: t`Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.`,
|
||||
},
|
||||
{
|
||||
question: t`Is Reactive Resume available in multiple languages?`,
|
||||
answer: (
|
||||
<Trans>
|
||||
Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings
|
||||
page, or using the language switcher in the top right corner. If you don't see your language, or you would like
|
||||
to improve the existing translations, you can{" "}
|
||||
<a
|
||||
href={crowdinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonVariants({ variant: "link", className: "h-auto px-0!" })}
|
||||
>
|
||||
contribute to the translations on Crowdin
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen reader hint indicating the FAQ translation contribution link opens in a new browser tab">
|
||||
{" "}
|
||||
(opens in new tab)
|
||||
</Trans>
|
||||
</span>
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t`What makes Reactive Resume different from other resume builders?`,
|
||||
answer: t`Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.`,
|
||||
},
|
||||
{
|
||||
question: t`Can I customize the templates?`,
|
||||
answer: t`Yes! Every template is fully customizable. You can change colors, fonts, spacing, and even write custom CSS for complete control over your resume's appearance.`,
|
||||
},
|
||||
{
|
||||
question: t`How do I share my resume?`,
|
||||
answer: t`You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!`,
|
||||
},
|
||||
];
|
||||
|
||||
export function FAQ() {
|
||||
const faqItems = getFaqItems();
|
||||
|
||||
return (
|
||||
<section
|
||||
id="frequently-asked-questions"
|
||||
className="flex flex-col gap-x-16 gap-y-6 p-4 md:p-8 lg:flex-row lg:gap-x-18 xl:py-16"
|
||||
>
|
||||
<motion.h2
|
||||
className={cn(
|
||||
"flex-1 font-semibold text-2xl tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
|
||||
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
|
||||
)}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<Trans context="Home page FAQ section heading with each word visually separated into individual spans">
|
||||
<span>Frequently</span>
|
||||
<span>Asked</span>
|
||||
<span>Questions</span>
|
||||
</Trans>
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45, delay: 0.08 }}
|
||||
className="max-w-2xl flex-2 will-change-[transform,opacity] lg:ml-auto 2xl:max-w-3xl"
|
||||
>
|
||||
<Accordion multiple>
|
||||
{faqItems.map((item, index) => (
|
||||
<FAQItemComponent key={item.question} item={item} index={index} />
|
||||
))}
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type FAQItemComponentProps = {
|
||||
item: FAQItemData;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity] last:border-b"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
|
||||
>
|
||||
<AccordionItem value={item.question} className="group border-t">
|
||||
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
|
||||
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CloudArrowUpIcon,
|
||||
CodeSimpleIcon,
|
||||
CurrencyDollarIcon,
|
||||
DatabaseIcon,
|
||||
DotsThreeIcon,
|
||||
FilePdfIcon,
|
||||
FilesIcon,
|
||||
GithubLogoIcon,
|
||||
GlobeIcon,
|
||||
KeyIcon,
|
||||
LayoutIcon,
|
||||
LockSimpleIcon,
|
||||
PaletteIcon,
|
||||
ProhibitIcon,
|
||||
ShieldCheckIcon,
|
||||
TranslateIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type Feature = {
|
||||
id: string;
|
||||
icon: Icon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FeatureCardProps = Feature;
|
||||
|
||||
const getFeatures = (): Feature[] => [
|
||||
{
|
||||
id: "free",
|
||||
icon: CurrencyDollarIcon,
|
||||
title: t`Free`,
|
||||
description: t`Completely free, forever, no hidden costs.`,
|
||||
},
|
||||
{
|
||||
id: "open-source",
|
||||
icon: GithubLogoIcon,
|
||||
title: t`Open Source`,
|
||||
description: t`By the community, for the community.`,
|
||||
},
|
||||
{
|
||||
id: "no-ads",
|
||||
icon: ProhibitIcon,
|
||||
title: t`No Advertising, No Tracking`,
|
||||
description: t`For a secure and distraction-free experience.`,
|
||||
},
|
||||
{
|
||||
id: "instant-generation",
|
||||
icon: FilePdfIcon,
|
||||
title: t`Instant Generation`,
|
||||
description: t`Export your resume to PDF instantly, without any waiting or delays.`,
|
||||
},
|
||||
{
|
||||
id: "data-security",
|
||||
icon: DatabaseIcon,
|
||||
title: t`Data Security`,
|
||||
description: t`Your data is secure, and never shared or sold to anyone.`,
|
||||
},
|
||||
{
|
||||
id: "self-host",
|
||||
icon: CloudArrowUpIcon,
|
||||
title: t`Self-Host with Docker`,
|
||||
description: t`You also have the option to deploy on your own servers using the Docker image.`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
icon: TranslateIcon,
|
||||
title: t`Multilingual`,
|
||||
description: t`Available in multiple languages. If you would like to contribute, check out Crowdin.`,
|
||||
},
|
||||
{
|
||||
id: "auth",
|
||||
icon: KeyIcon,
|
||||
title: t`One-Click Sign-In`,
|
||||
description: t`Sign in with GitHub, Google or a custom OAuth provider.`,
|
||||
},
|
||||
{
|
||||
id: "2fa",
|
||||
icon: ShieldCheckIcon,
|
||||
title: t`Passkeys & 2FA`,
|
||||
description: t`Enhance the security of your account with additional layers of protection.`,
|
||||
},
|
||||
{
|
||||
id: "unlimited-resumes",
|
||||
icon: FilesIcon,
|
||||
title: t`Unlimited Resumes`,
|
||||
description: t`Create as many resumes as you want, without limits.`,
|
||||
},
|
||||
{
|
||||
id: "design",
|
||||
icon: PaletteIcon,
|
||||
title: t`Flexibility`,
|
||||
description: t`Personalize your resume with any colors, fonts or designs, and make it your own.`,
|
||||
},
|
||||
{
|
||||
id: "templates",
|
||||
icon: LayoutIcon,
|
||||
title: t`12+ Templates`,
|
||||
description: t`Beautiful templates to choose from, with more on the way.`,
|
||||
},
|
||||
{
|
||||
id: "public",
|
||||
icon: GlobeIcon,
|
||||
title: t`Shareable Links`,
|
||||
description: t`Share your resume with a public URL, and let others view it.`,
|
||||
},
|
||||
{
|
||||
id: "password-protection",
|
||||
icon: LockSimpleIcon,
|
||||
title: t`Password Protection`,
|
||||
description: t`Protect your resume with a password, and let only people with the password view it.`,
|
||||
},
|
||||
{
|
||||
id: "api-access",
|
||||
icon: CodeSimpleIcon,
|
||||
title: t`API Access`,
|
||||
description: t`Access your resumes and data programmatically using the API.`,
|
||||
},
|
||||
{
|
||||
id: "more",
|
||||
icon: DotsThreeIcon,
|
||||
title: t`And many more...`,
|
||||
description: t`New features are constantly being added and improved, so be sure to check back often.`,
|
||||
},
|
||||
];
|
||||
|
||||
function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
|
||||
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
|
||||
"hover:bg-secondary/30",
|
||||
)}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.1 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
{/* Hover gradient overlay */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-linear-to-br from-primary/5 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div aria-hidden="true" className="relative">
|
||||
<div className="inline-flex rounded-md bg-primary/5 p-2.5 text-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
|
||||
<Icon size={24} weight="thin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex flex-col gap-y-1.5">
|
||||
<h3 className="font-semibold text-base tracking-tight transition-colors group-hover:text-primary">{title}</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
const features = useMemo(() => getFeatures(), []);
|
||||
|
||||
return (
|
||||
<section id="features">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Features</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-2xl text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Everything you need to create, customize, and share professional resumes. Built with privacy in mind,
|
||||
powered by open source, and completely free forever.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 border-t xl:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard key={feature.id} {...feature} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
|
||||
type FooterLinkItem = {
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type FooterLinkGroupProps = {
|
||||
title: string;
|
||||
links: FooterLinkItem[];
|
||||
};
|
||||
|
||||
type SocialLink = {
|
||||
url: string;
|
||||
label: string;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const getResourceLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://docs.rxresu.me", label: t`Documentation` },
|
||||
{ url: "https://opencollective.com/reactive-resume", label: t`Sponsorships` },
|
||||
{ url: "https://github.com/amruthpillai/reactive-resume", label: t`Source Code` },
|
||||
{ url: "https://docs.rxresu.me/changelog", label: t`Changelog` },
|
||||
];
|
||||
|
||||
const getCommunityLinks = (): FooterLinkItem[] => [
|
||||
{ url: "https://github.com/amruthpillai/reactive-resume/issues", label: t`Report an issue` },
|
||||
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
|
||||
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
|
||||
{ url: "https://discord.gg/aSyA5ZSxpb", label: t`Discord` },
|
||||
];
|
||||
|
||||
const socialLinks: SocialLink[] = [
|
||||
{ url: "https://github.com/amruthpillai/reactive-resume", label: t`GitHub`, icon: GithubLogoIcon },
|
||||
{ url: "https://linkedin.com/in/amruthpillai", label: t`LinkedIn`, icon: LinkedinLogoIcon },
|
||||
{ url: "https://x.com/KingOKings", label: t`X (Twitter)`, icon: XLogoIcon },
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<motion.footer
|
||||
id="footer"
|
||||
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand Column */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<BrandIcon variant="logo" className="size-10" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-bold text-lg tracking-tight">Reactive Resume</h2>
|
||||
<p className="max-w-xs text-muted-foreground text-sm leading-relaxed">
|
||||
<Trans>
|
||||
A free and open-source resume builder that simplifies the process of creating, updating, and sharing
|
||||
your resume.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{socialLinks.map((social) => (
|
||||
<Button
|
||||
key={social.label}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${social.label} (${t`opens in new tab`})`}
|
||||
>
|
||||
<social.icon aria-hidden="true" size={18} />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resources Column */}
|
||||
<FooterLinkGroup title={t`Resources`} links={getResourceLinks()} />
|
||||
|
||||
{/* Community Column */}
|
||||
<FooterLinkGroup title={t`Community`} links={getCommunityLinks()} />
|
||||
|
||||
{/* Copyright Column */}
|
||||
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
|
||||
<Copyright />
|
||||
</div>
|
||||
</div>
|
||||
</motion.footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium text-muted-foreground text-sm tracking-tight">{title}</h2>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<FooterLink key={link.url} url={link.url} label={link.label} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ url, label }: FooterLinkItem) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="relative inline-block text-sm transition-colors hover:text-foreground"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{label}
|
||||
|
||||
<span className="sr-only">
|
||||
<Trans>(opens in new tab)</Trans>
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion, useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { GithubStarsButton } from "@/components/input/github-stars-button";
|
||||
import { LocaleCombobox } from "@/components/locale/combobox";
|
||||
import { ThemeToggleButton } from "@/components/theme/toggle-button";
|
||||
|
||||
export function Header() {
|
||||
const y = useMotionValue(0);
|
||||
const lastScroll = useRef(0);
|
||||
const ticking = useRef(false);
|
||||
const springY = useSpring(y, { stiffness: 300, damping: 40 });
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
function onScroll() {
|
||||
const current = window.scrollY ?? 0;
|
||||
if (!ticking.current) {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (current > 32 && current > lastScroll.current) {
|
||||
// Scrolling down, hide
|
||||
y.set(-100);
|
||||
} else {
|
||||
// Scrolling up, show
|
||||
y.set(0);
|
||||
}
|
||||
lastScroll.current = current;
|
||||
ticking.current = false;
|
||||
});
|
||||
ticking.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, [y]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
style={{ y: springY }}
|
||||
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
<nav aria-label={t`Main navigation`} className="container mx-auto flex items-center gap-x-4 p-3 lg:px-12">
|
||||
<Link to="/" className="transition-opacity hover:opacity-80" aria-label={t`Reactive Resume - Go to homepage`}>
|
||||
<BrandIcon className="size-10" />
|
||||
</Link>
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
<LocaleCombobox
|
||||
render={
|
||||
<Button size="icon" variant="ghost" aria-label={t`Change language`}>
|
||||
<TranslateIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ThemeToggleButton />
|
||||
|
||||
<div className="hidden items-center gap-x-4 sm:flex">
|
||||
<GithubStarsButton />
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
nativeButton={false}
|
||||
aria-label={t`Go to dashboard`}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<ArrowRightIcon aria-hidden="true" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, BookIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
import { Spotlight } from "@/components/animation/spotlight";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section
|
||||
id="hero"
|
||||
className="relative flex min-h-svh w-full flex-col items-center justify-center overflow-hidden border-b py-24"
|
||||
>
|
||||
<Spotlight />
|
||||
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.1, ease: "easeOut" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="relative -mb-12 3xl:max-w-7xl max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<video
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
// @ts-expect-error - typescript doesn't know about fetchPriority for video elements
|
||||
fetchPriority="high"
|
||||
src="/videos/timelapse.mp4"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none size-full rounded-md border object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-40% via-transparent to-background"
|
||||
/>
|
||||
</CometCard>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 xs:px-0 text-center">
|
||||
{/* Badge */}
|
||||
<motion.a
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.55 }}
|
||||
whileHover={{ y: -2, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.985 }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.rxresu.me/getting-started"
|
||||
>
|
||||
<Badge variant="secondary" className="h-auto gap-1.5 px-3 py-0.5">
|
||||
<SparkleIcon aria-hidden="true" className="size-3.5" weight="fill" />
|
||||
<Trans>What's new in the latest version?</Trans>
|
||||
</Badge>
|
||||
</motion.a>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.7 }}
|
||||
>
|
||||
<Trans>
|
||||
<p className="font-medium text-muted-foreground tracking-tight md:text-lg">Finally,</p>
|
||||
<h1 className="mt-1 font-bold text-4xl tracking-tight md:text-5xl lg:text-6xl">
|
||||
A free and open-source resume builder
|
||||
</h1>
|
||||
</Trans>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
className="max-w-xl text-base text-muted-foreground leading-relaxed will-change-[transform,opacity] md:text-lg"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.82 }}
|
||||
>
|
||||
<Trans>
|
||||
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
|
||||
and sharing your resume.
|
||||
</Trans>
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, delay: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
nativeButton={false}
|
||||
className="group relative overflow-hidden px-4"
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<Trans>Get Started</Trans>
|
||||
<ArrowRightIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="gap-2 px-4"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<BookIcon aria-hidden="true" className="size-4" />
|
||||
<Trans>Learn More</Trans>
|
||||
<span className="sr-only">
|
||||
<Trans>(opens in new tab)</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - decorative */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
className="absolute inset-s-1/2 bottom-8 -translate-x-1/2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.25, duration: 0.7 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
|
||||
animate={{ y: [0, 5, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
|
||||
>
|
||||
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { TextMaskEffect } from "@/components/animation/text-mask";
|
||||
|
||||
export function Prefooter() {
|
||||
return (
|
||||
<section id="prefooter" className="relative overflow-hidden py-16 md:py-24">
|
||||
{/* Background decoration */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute inset-s-1/4 top-0 size-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="absolute inset-e-1/4 bottom-0 size-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-8">
|
||||
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
|
||||
|
||||
<motion.div
|
||||
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<h2 className="font-bold text-2xl tracking-tight md:text-4xl">
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Reactive Resume continues to grow thanks to its vibrant community. This project owes its progress to
|
||||
numerous individuals who've dedicated their time and skills to make it better. We celebrate the coders
|
||||
who've enhanced its features on GitHub, the linguists whose translations on Crowdin have made it
|
||||
accessible to a broader audience, and the people who've donated to support its continued development.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FileTextIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { CountUp } from "@/components/animation/count-up";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type Statistic = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const getStatistics = (userCount: number, resumeCount: number): Statistic[] => [
|
||||
{
|
||||
id: "users",
|
||||
label: t`Users`,
|
||||
value: userCount,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
id: "resumes",
|
||||
label: t`Resumes`,
|
||||
value: resumeCount,
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
type StatisticCardProps = {
|
||||
statistic: Statistic;
|
||||
index: number;
|
||||
};
|
||||
|
||||
function StatisticCard({ statistic, index }: StatisticCardProps) {
|
||||
const Icon = statistic.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative flex flex-col items-center justify-center gap-y-4 border-r border-b p-8 transition-colors last:border-e-0 hover:bg-secondary/30 sm:border-b-0 xl:py-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1, ease: "easeOut" }}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute inset-s-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-primary/2"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: index * 0.1 + 0.2 }}
|
||||
>
|
||||
<Icon size={180} weight="thin" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="relative rounded-full bg-primary/10 p-3 text-primary"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 20 }}
|
||||
>
|
||||
<Icon size={24} weight="thin" />
|
||||
</motion.div>
|
||||
|
||||
{/* Value */}
|
||||
<CountUp
|
||||
key={statistic.id}
|
||||
separator=","
|
||||
duration={0.8}
|
||||
to={statistic.value}
|
||||
className="font-bold text-5xl tracking-tight md:text-6xl"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<p className="relative font-medium text-base text-muted-foreground tracking-tight">{statistic.label}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Statistics() {
|
||||
const [userCountResult, resumeCountResult] = useQueries({
|
||||
queries: [orpc.statistics.user.getCount.queryOptions(), orpc.statistics.resume.getCount.queryOptions()],
|
||||
});
|
||||
|
||||
if (!userCountResult.data || !resumeCountResult.data) return null;
|
||||
|
||||
return (
|
||||
<section id="statistics" aria-labelledby="stats-heading">
|
||||
<h2 id="stats-heading" className="sr-only">
|
||||
{t`Application Statistics`}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2">
|
||||
{getStatistics(userCountResult.data, resumeCountResult.data).map((statistic, index) => (
|
||||
<StatisticCard key={statistic.id} statistic={statistic} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
|
||||
type TemplateItemProps = {
|
||||
metadata: TemplateMetadata;
|
||||
};
|
||||
|
||||
function TemplateItem({ metadata }: TemplateItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative shrink-0 will-change-transform"
|
||||
initial={{ scale: 1, zIndex: 10 }}
|
||||
whileHover={{ scale: 1.06, zIndex: 20 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 26 }}
|
||||
>
|
||||
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
|
||||
{/* Subtle overlay on hover */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* Template name on hover */}
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-full p-4 transition-transform duration-300 group-hover:translate-y-0">
|
||||
<p className="font-semibold text-white drop-shadow-lg">{metadata.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Shine effect on hover */}
|
||||
<div className="pointer-events-none absolute inset-0 -translate-x-full rotate-12 bg-linear-to-r from-transparent via-white/10 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type MarqueeRowProps = {
|
||||
templates: Array<[string, TemplateMetadata]>;
|
||||
rowId: string;
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeRow({ templates, rowId, direction, duration = 40 }: MarqueeRowProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex gap-x-4 will-change-transform sm:gap-x-6"
|
||||
animate={{ x: animateX }}
|
||||
transition={{
|
||||
x: {
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: "loop",
|
||||
duration,
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{templates.map(([template, metadata], index) => (
|
||||
<TemplateItem key={`${rowId}-${template}-${index}`} metadata={metadata} />
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Templates() {
|
||||
// Split templates into two rows and duplicate for seamless infinite scroll
|
||||
const { row1, row2 } = useMemo(() => {
|
||||
const entries = Object.entries(templates);
|
||||
const half = Math.ceil(entries.length / 2);
|
||||
const firstHalf = entries.slice(0, half);
|
||||
const secondHalf = entries.slice(half);
|
||||
|
||||
// Duplicate each row for seamless scrolling
|
||||
return {
|
||||
row1: [...firstHalf, ...firstHalf],
|
||||
row2: [...secondHalf, ...secondHalf],
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
|
||||
<motion.div
|
||||
className="space-y-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Templates</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-2xl text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
Explore our diverse selection of templates, each designed to fit different styles, professions, and
|
||||
personalities. Reactive Resume currently offers 12 templates, with more on the way.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative mt-8 -rotate-3 py-8 sm:-rotate-4 lg:mt-0 lg:-rotate-5">
|
||||
{/* Marquee container with minimum height */}
|
||||
<div className="flex min-h-[280px] flex-col gap-y-4 sm:min-h-[320px] sm:gap-y-6 md:min-h-[380px] lg:min-h-[420px]">
|
||||
{/* First row - moves left to right */}
|
||||
<MarqueeRow templates={row1} rowId="row1" direction="left" duration={45} />
|
||||
|
||||
{/* Second row - moves right to left (opposite direction) */}
|
||||
<MarqueeRow templates={row2} rowId="row2" direction="right" duration={50} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { QuotesIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const email = "hello@amruthpillai.com";
|
||||
|
||||
const testimonials: string[] = [
|
||||
"Great site. Love the interactive interface. You can tell it's designed by someone who wants to use it.",
|
||||
|
||||
"Truly everything about the UX is so intuitive, fluid and lets you customize your CV how you want and so rapidly. I thank you so much for putting the work to release something like this.",
|
||||
|
||||
"I want to appreciate you for making your projects #openSource, most especially your Reactive Resume, which is the handiest truly-free resume maker I've come across. This is a big shoutout to you. Well done!",
|
||||
|
||||
"I'd like to appreciate the great work you've done with rxresu.me. The website's design, smooth functionality, and ease of use under the free plan are really impressive. It's clear that a lot of thought and effort has gone into building and maintaining such a useful platform.",
|
||||
|
||||
"I just wanted to reach you out and thank you personally for your wonderful project rxresu.me. It is very valuable, and the fact that it is open source, makes it all the more meaningful, since there are lots of people who struggle to make their CV look good. For my part, it saved me a lot of time and helped me shape my CV in a very efficient way.",
|
||||
|
||||
"I appreciate your effort in open-sourcing and making it free for everyone to use, it's a great effort. By using this platform, I got a job secured in the government sector of Oman, that too in a ministry. Thank you for providing this platform. Keep going, appreciate the effort. ❤️",
|
||||
|
||||
"Your CV generator just saved my day! Thank you so much, great work!",
|
||||
|
||||
"I want to express my heartfelt gratitude and admiration for your incredible work and remarkable skills. Your projects, especially the Resume Builder, have been immensely helpful to me, and I deeply appreciate the effort and creativity you've poured into them.",
|
||||
|
||||
"Hey! Thank you so much for making this fantastic tool! It helped me get a new job as a Research Software Engineer at Arizona State University.",
|
||||
|
||||
"Wow, what an impressive profile! You are very talented. I'm also a fellow SWE on the job hunt and I came across a linked to Reactive Resume on Reddit and gave it a shot. This could easily be a paid product. Very clean and useful.",
|
||||
|
||||
"Thank you for creating Reactive Resume. It is an amazing product, and I love the design and how it simplifies the resume-making experience. I've been trying to create a good resume for a decade to find my first job in tech, and your tool has been incredibly helpful.",
|
||||
];
|
||||
|
||||
type TestimonialCardProps = {
|
||||
testimonial: string;
|
||||
};
|
||||
|
||||
function TestimonialCard({ testimonial }: TestimonialCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="group relative flex w-full flex-col overflow-hidden text-pretty rounded-2xl border bg-card p-4 will-change-transform"
|
||||
initial={{ scale: 1, boxShadow: "0 0 20px 0 rgba(0, 0, 0, 0)" }}
|
||||
whileHover={{ scale: 1.2, zIndex: 100, boxShadow: "0 0 40px 0 rgba(0, 0, 0, 0.5)" }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
>
|
||||
<QuotesIcon
|
||||
weight="fill"
|
||||
className="absolute -right-2 -bottom-4 size-18 opacity-10 transition-[bottom] duration-200 group-hover:-bottom-16"
|
||||
/>
|
||||
<p className="flex-1 text-muted-foreground leading-relaxed">{testimonial}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
type TestimonialColumnProps = {
|
||||
columnId: string;
|
||||
testimonials: string[];
|
||||
};
|
||||
|
||||
function TestimonialColumn({ columnId, testimonials }: TestimonialColumnProps) {
|
||||
return (
|
||||
<div className="flex w-[320px] shrink-0 flex-col gap-y-4 sm:w-[360px] md:w-[400px]">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<TestimonialCard key={`${columnId}-${index}`} testimonial={testimonial} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MarqueeMasonryProps = {
|
||||
columns: string[][];
|
||||
direction: "left" | "right";
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryProps) {
|
||||
const animateX = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"];
|
||||
const marqueeColumns = [...columns, ...columns];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-start gap-x-4 will-change-transform"
|
||||
animate={{ x: animateX }}
|
||||
transition={{ x: { repeat: Number.POSITIVE_INFINITY, repeatType: "loop", duration, ease: "linear" } }}
|
||||
>
|
||||
{marqueeColumns.map((column, index) => (
|
||||
<TestimonialColumn key={index} columnId={`column-${index}`} testimonials={column} />
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Testimonials() {
|
||||
const columns = useMemo(() => {
|
||||
const columns: string[][] = [];
|
||||
|
||||
for (let index = 0; index < testimonials.length; index += 2) {
|
||||
columns.push(testimonials.slice(index, index + 2));
|
||||
}
|
||||
|
||||
return columns;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
|
||||
<motion.div
|
||||
className="mb-10 flex flex-col items-center space-y-4 px-4 text-center md:px-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h2 className="font-semibold text-2xl tracking-tight md:text-4xl xl:text-5xl">
|
||||
<Trans>Testimonials</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-4xl text-balance text-muted-foreground leading-relaxed">
|
||||
<Trans>
|
||||
A lot of people have written to me over the years to share their experiences with Reactive Resume and how it
|
||||
has helped them, and I never get tired of reading them. If you have a story to share, let me know by sending
|
||||
me an email at{" "}
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-foreground underline underline-offset-2 transition-colors hover:text-primary"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Left fade */}
|
||||
<div className="pointer-events-none absolute inset-s-0 top-0 bottom-0 z-10 w-16 bg-linear-to-r from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
|
||||
|
||||
{/* Right fade */}
|
||||
<div className="pointer-events-none absolute inset-e-0 top-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
|
||||
|
||||
<MarqueeMasonry columns={columns} direction="left" duration={60} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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:inset-s-4 focus:top-4 focus:z-100 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-foreground focus:shadow-lg focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<Header />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
"prompt",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"response_type",
|
||||
"scope",
|
||||
"state",
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||
try {
|
||||
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||
if (URL.canParse(decodedRedirectUri)) {
|
||||
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||
}
|
||||
}
|
||||
|
||||
if (url.toString() === request.url) return request;
|
||||
return new Request(url, request);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
// Claude.ai sends token_endpoint_auth_method: "client_secret_post" without a
|
||||
// client_secret, causing Better Auth to require authentication for what is
|
||||
// effectively a public client. Force to "none" for unauthenticated registrations.
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||
if (request.method !== "POST") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
} catch {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
||||
const oauthDynamicClientRedirectHosts = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS);
|
||||
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||
for (const redirectUri of redirectUris) {
|
||||
if (
|
||||
typeof redirectUri !== "string" ||
|
||||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, oauthDynamicClientRedirectHosts)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||
if (registrationValidationError) return registrationValidationError;
|
||||
|
||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handler,
|
||||
POST: handler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Server-only API route. Lazy-imports keep db/storage/drizzle out of the client bundle.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getStorageService } from "@reactive-resume/api/services/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
type CheckResult = {
|
||||
status: "healthy" | "unhealthy";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
return { ...(data as object), status: "healthy", latencyMs };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function healthHandler() {
|
||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
|
||||
}
|
||||
|
||||
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" ? 503 : 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();
|
||||
return await storageService.healthcheck();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: healthHandler,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { getLocale } from "@/libs/locale";
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const openAPIHandler = new OpenAPIHandler(router, {
|
||||
plugins: [
|
||||
new BatchHandlerPlugin(),
|
||||
new RequestHeadersPlugin(),
|
||||
new StrictGetMethodPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[OpenAPI]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(router, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: __APP_VERSION__,
|
||||
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" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
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,33 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { getLocale } from "@/libs/locale";
|
||||
|
||||
async function handler({ request }: { request: Request }) {
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[oRPC Server]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { handler } from "../../uploads/$userId.$";
|
||||
|
||||
export const Route = createFileRoute("/api/uploads/$userId/$")({
|
||||
server: { handlers: { GET: handler } },
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type SocialAuthProps = {
|
||||
requestSignUp?: boolean;
|
||||
};
|
||||
|
||||
type SocialSignInOptions = {
|
||||
provider: string;
|
||||
callbackURL: string;
|
||||
requestSignUp?: true;
|
||||
};
|
||||
|
||||
function getSocialSignInOptions(provider: string, requestSignUp: boolean): SocialSignInOptions {
|
||||
const options: SocialSignInOptions = { provider, callbackURL: "/dashboard" };
|
||||
if (requestSignUp) options.requestSignUp = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
export function SocialAuth({ requestSignUp = false }: SocialAuthProps) {
|
||||
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
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>
|
||||
|
||||
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} requestSignUp={requestSignUp} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialAuthSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SocialAuthButtonsProps = {
|
||||
providers: RouterOutput["auth"]["providers"]["list"];
|
||||
requestSignUp: boolean;
|
||||
};
|
||||
|
||||
function SocialAuthButtons({ providers, requestSignUp }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.social(getSocialSignInOptions(provider, requestSignUp));
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when social sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await 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 ||
|
||||
t({
|
||||
comment: "Fallback toast when custom OAuth sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: false });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when passkey sign-in fails without an error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in providers && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{providers.custom}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePasskeyLogin}
|
||||
className={cn("hidden", "passkey" in providers && "inline-flex")}
|
||||
>
|
||||
<FingerprintIcon />
|
||||
<Trans comment="Label for passkey sign-in button">Passkey</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
<Trans comment="Brand name label for Google social sign-in button">Google</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
<Trans comment="Brand name label for GitHub social sign-in button">GitHub</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("linkedin")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
|
||||
"linkedin" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
<Trans comment="Brand name label for LinkedIn social sign-in button">LinkedIn</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
export const Route = createFileRoute("/auth/forgot-password")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Sending password reset email...`);
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: value.email,
|
||||
redirectTo: "/auth/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when requesting password reset email fails without backend message",
|
||||
message: "Failed to send password reset email. Please try again.",
|
||||
}),
|
||||
{ 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
|
||||
variant="link"
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from forgot-password page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on forgot-password form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on forgot-password form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on forgot-password form">Send Password Reset Email</Trans>
|
||||
</Button>
|
||||
</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
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="mailto:">
|
||||
<Trans comment="Button label to open the user's default email app">Open Email Client</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,243 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, redirect, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
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),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const hasStartedConditionalPasskeyRef = useRef(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { data: providers = {} } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { identifier: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
try {
|
||||
const isEmail = value.identifier.includes("@");
|
||||
|
||||
const result = isEmail
|
||||
? await authClient.signIn.email({ email: value.identifier, password: value.password })
|
||||
: await authClient.signIn.username({ username: value.identifier, password: value.password });
|
||||
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when sign-in fails and no server error message is available",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTwoFactor =
|
||||
result.data &&
|
||||
typeof result.data === "object" &&
|
||||
"twoFactorRedirect" in result.data &&
|
||||
result.data.twoFactorRedirect;
|
||||
|
||||
if (requiresTwoFactor) {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: "/auth/verify-2fa", replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void navigate({ to: "/dashboard", replace: true });
|
||||
} catch {
|
||||
toast.error(t`Failed to sign in. Please try again.`, { id: toastId });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!("passkey" in providers)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("PublicKeyCredential" in window)) return;
|
||||
if (!PublicKeyCredential.isConditionalMediationAvailable) return;
|
||||
if (hasStartedConditionalPasskeyRef.current) return;
|
||||
|
||||
hasStartedConditionalPasskeyRef.current = true;
|
||||
|
||||
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: true });
|
||||
if (error) return;
|
||||
|
||||
await router.invalidate();
|
||||
});
|
||||
}, [providers, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="font-bold text-2xl tracking-tight">
|
||||
<Trans comment="Title on the login page">Sign in to your account</Trans>
|
||||
</h1>
|
||||
|
||||
{!flags.disableSignups && (
|
||||
<div className="text-muted-foreground">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/register">
|
||||
<Trans comment="Call-to-action link from login page to account registration page">
|
||||
Create one now
|
||||
</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!flags.disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="identifier">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for login identifier input that accepts email or username">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
autoComplete="section-login username webauthn"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder for login identifier field",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
<FormDescription>
|
||||
<Trans>You can also use your username to login.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on login form">Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Button
|
||||
tabIndex={-1}
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto p-0 text-xs leading-none"
|
||||
render={
|
||||
<Link to="/auth/forgot-password">
|
||||
<Trans comment="Link label to password reset page from login form">Forgot Password?</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-login current-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides the password in login form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals the password in login form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on login form">Sign in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import crypto from "node:crypto";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/auth/oauth")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000); // 10 min
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
});
|
||||
}
|
||||
|
||||
// Not logged in — redirect to the real login page with a callback
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!["exp", "sig"].includes(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/auth/oauth?${oauthParams.toString()}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: loginUrl.toString() },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
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 });
|
||||
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", 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),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const { flags } = Route.useRouteContext();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: "", username: "", email: "", password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Signing up...`);
|
||||
|
||||
const { error } = await authClient.signUp.email({
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
username: value.username,
|
||||
displayUsername: value.username,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when account registration fails without a server error message",
|
||||
message: "Failed to create your account. Please try again.",
|
||||
}),
|
||||
{ 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
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto gap-1.5 px-1! py-0"
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!flags.disableEmailAuth && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for full name input on registration form">Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register name"
|
||||
placeholder={t({
|
||||
comment: "Example full name placeholder on registration form",
|
||||
message: "John Doe",
|
||||
})}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="username">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for username input on registration form">Username</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={3}
|
||||
max={64}
|
||||
autoComplete="section-register username"
|
||||
placeholder={t({
|
||||
comment: "Example username placeholder on registration form",
|
||||
message: "john.doe",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for email input on registration form">Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="section-register email"
|
||||
placeholder={t({
|
||||
comment: "Example email placeholder on registration form",
|
||||
message: "john.doe@example.com",
|
||||
})}
|
||||
className="lowercase"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on registration form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="section-register new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in registration form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in registration form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on registration form">Sign up</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<SocialAuth requestSignUp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard">
|
||||
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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 { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
const searchSchema = z.object({ token: z.string().min(1) });
|
||||
|
||||
export const Route = createFileRoute("/auth/reset-password")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof SearchParamError) {
|
||||
throw redirect({ to: "/auth/login" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { token } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Resetting your password...`);
|
||||
|
||||
const { error } = await authClient.resetPassword({ token, newPassword: value.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when resetting password fails and no backend message is available",
|
||||
message: "Failed to reset your password. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been reset successfully. You can now sign in with your new password.`, {
|
||||
id: toastId,
|
||||
});
|
||||
|
||||
void 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
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for new password input on reset-password form">New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password in reset-password form",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password in reset-password form",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<Trans comment="Primary action button label on reset-password form">Reset Password</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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 { toast } from "sonner";
|
||||
import { useToggle } from "usehooks-ts";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const { redirect } = Route.useSearch();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
|
||||
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.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 = useAppForm({
|
||||
defaultValues: { password: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
const toastId = toast.loading(t`Verifying password...`);
|
||||
|
||||
verifyPassword(
|
||||
{ username, slug, password: value.password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.dismiss(toastId);
|
||||
void navigate({ to: redirect, replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ORPCError && error.code === "INVALID_PASSWORD") {
|
||||
toast.dismiss(toastId);
|
||||
formApi.setFieldMeta("password", (meta) => ({
|
||||
...meta,
|
||||
isTouched: true,
|
||||
errors: [{ message: t`The password you entered is incorrect` }],
|
||||
errorMap: {
|
||||
...meta.errorMap,
|
||||
onSubmit: { message: t`The password you entered is incorrect` },
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when resume password verification fails unexpectedly",
|
||||
message: "Failed to verify the password. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ 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
|
||||
className="space-y-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans comment="Label for password input on protected resume access form">Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={
|
||||
showPassword
|
||||
? t({
|
||||
comment: "Accessible label for button that hides password on protected resume screen",
|
||||
message: "Hide password",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for button that reveals password on protected resume screen",
|
||||
message: "Show password",
|
||||
})
|
||||
}
|
||||
>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
<LockOpenIcon />
|
||||
<Trans comment="Primary action button label to unlock a password-protected resume">Unlock</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/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,118 @@
|
||||
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 { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying backup code...`);
|
||||
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
|
||||
message: "Failed to verify your backup code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void 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
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
maxLength={10}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/verify-2fa">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit backup code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { code: "" },
|
||||
validators: { onSubmit: formSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: value.code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when verifying a two-factor authentication code fails",
|
||||
message: "Failed to verify your code. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
void 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
|
||||
className="grid gap-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="code">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="justify-self-center"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="number"
|
||||
maxLength={6}
|
||||
className="max-w-xs"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/auth/login">
|
||||
<ArrowLeftIcon />
|
||||
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="flex-1">
|
||||
<CheckIcon />
|
||||
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto justify-self-center p-0 text-sm"
|
||||
render={
|
||||
<Link to="/auth/verify-2fa-backup">
|
||||
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
|
||||
Lost access to your authenticator?
|
||||
</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowUUpLeftIcon,
|
||||
ArrowUUpRightIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useHotkeys } from "@tanstack/react-hotkeys";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useControls } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useResumeHistory } from "@/components/resume/use-resume";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
|
||||
export function BuilderDock() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { zoomIn, zoomOut, centerView } = useControls();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const { undo, redo, canUndo, canRedo } = useResumeHistory();
|
||||
|
||||
useHotkeys([
|
||||
{ hotkey: "Mod+Z", callback: () => undo() },
|
||||
{ hotkey: "Mod+Y", callback: () => redo() },
|
||||
]);
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session?.user.username, resume?.slug]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
|
||||
>
|
||||
<DockIcon
|
||||
disabled={!canUndo}
|
||||
onClick={() => undo()}
|
||||
icon={ArrowUUpLeftIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Undo (Ctrl+Z)",
|
||||
})}
|
||||
/>
|
||||
<DockIcon
|
||||
disabled={!canRedo}
|
||||
onClick={() => redo()}
|
||||
icon={ArrowUUpRightIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Redo (Ctrl+Y)",
|
||||
})}
|
||||
/>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.1)} />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
|
||||
<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 icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
<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
|
||||
render={
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TooltipContent side="top" align="center" className="font-medium">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@reactive-resume/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 min-h-0 w-12 flex-col items-center overflow-hidden bg-popover py-2.5 sm:flex",
|
||||
side === "left" ? "inset-s-0 border-r" : "inset-e-0 border-l",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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 "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const resume = useCurrentResume();
|
||||
const name = resume.name;
|
||||
const isLocked = resume.isLocked;
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-0 z-50 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
|
||||
Toggle left sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for button navigating from builder to resumes dashboard",
|
||||
message: "Go to resumes dashboard",
|
||||
})}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
<HouseSimpleIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<span className="me-2.5 text-muted-foreground">/</span>
|
||||
<h2 className="flex-1 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
|
||||
<BuilderHeaderDropdown />
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
|
||||
Toggle right sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderHeaderDropdown() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
const id = resume.id;
|
||||
const name = resume.name;
|
||||
const slug = resume.slug;
|
||||
const tags = resume.tags;
|
||||
const isLocked = 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 },
|
||||
{
|
||||
onSuccess: () => {
|
||||
patchResume((draft) => {
|
||||
draft.isLocked = !isLocked;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 });
|
||||
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon className="me-2" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon className="me-2" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
|
||||
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon className="me-2" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { Suspense } from "react";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { BuilderDock } from "./dock";
|
||||
|
||||
export function PreviewPage() {
|
||||
useHotkey("Mod+S", () => {
|
||||
toast.info(t`Your changes are saved automatically.`, { id: "auto-save", icon: <FloppyDiskIcon /> });
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<div className="fixed inset-0">
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
maxScale={6}
|
||||
minScale={0.3}
|
||||
initialScale={0.6}
|
||||
limitToBounds={false}
|
||||
wheel={{ step: 0.001 }}
|
||||
>
|
||||
<TransformComponent wrapperClass="h-full! w-full!">
|
||||
<ResumePreview pageGap="2rem" showPageNumbers />
|
||||
</TransformComponent>
|
||||
|
||||
<BuilderDock />
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
|
||||
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
|
||||
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:ms-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 className="flex min-h-0 w-full flex-1 flex-col items-center gap-y-2 overflow-hidden">
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center 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>
|
||||
</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>
|
||||
</div>
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { awardItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.awards;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,194 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { basicsSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
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 = useCurrentBuilderResumeSelector((resume) => resume.data.basics);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.basics = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: basics,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="headline">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Headline</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="phone">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Phone</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="location">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="website">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<CustomFieldsSection form={form} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { certificationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.certifications;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,184 @@
|
||||
import type { basicsSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { withForm } from "@/libs/tanstack-form";
|
||||
|
||||
type FormValues = z.infer<typeof basicsSchema>;
|
||||
type CustomField = FormValues["customFields"][number];
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
name: "",
|
||||
headline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
website: { url: "", label: "" },
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
export const CustomFieldsSection = withForm({
|
||||
defaultValues,
|
||||
render: ({ form }) => {
|
||||
return (
|
||||
<form.Field name="customFields" mode="array">
|
||||
{(customFieldsField) => (
|
||||
<Reorder.Group
|
||||
className="touch-none space-y-4"
|
||||
values={customFieldsField.state.value}
|
||||
onReorder={(fields) => {
|
||||
customFieldsField.setValue(fields);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
{customFieldsField.state.value.map((field: CustomField, index: number) => (
|
||||
<CustomFieldItem key={field.id} field={field}>
|
||||
<form.Field name={`customFields[${index}].icon`}>
|
||||
{(iconField) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker
|
||||
name={iconField.name}
|
||||
value={iconField.state.value}
|
||||
className="rounded-r-none! border-e-0!"
|
||||
onChange={(icon) => {
|
||||
iconField.handleChange(icon);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name={`customFields[${index}].text`}>
|
||||
{(textField) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={textField.name}
|
||||
value={textField.state.value}
|
||||
className="rounded-l-none!"
|
||||
onChange={(e) => {
|
||||
textField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name={`customFields[${index}].link`}>
|
||||
{(linkField) => (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" className="ms-1">
|
||||
<LinkIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent align="center">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<Label htmlFor={linkField.name} className="text-muted-foreground text-xs">
|
||||
<Trans>Enter the URL to link to</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="url"
|
||||
value={linkField.state.value}
|
||||
id={linkField.name}
|
||||
placeholder={t({
|
||||
comment: "Placeholder text for custom link URL field in resume builder",
|
||||
message: "Must start with https://",
|
||||
})}
|
||||
onChange={(e) => {
|
||||
linkField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.removeValue(index);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</CustomFieldItem>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ListPlusIcon />
|
||||
<Trans>Add a custom field</Trans>
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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="me-2 touch-none"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
CustomSection,
|
||||
CustomSectionItem as CustomSectionItemType,
|
||||
CustomSectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
CopySimpleIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { stripHtml } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string {
|
||||
return match(type)
|
||||
.with("summary", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is empty",
|
||||
message: "Summary",
|
||||
});
|
||||
}
|
||||
return t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
|
||||
message: "Summary",
|
||||
});
|
||||
})
|
||||
.with("profiles", () => ("network" in item ? item.network : ""))
|
||||
.with("experience", () => ("company" in item ? item.company : ""))
|
||||
.with("education", () => ("school" in item ? item.school : ""))
|
||||
.with("projects", () => ("name" in item ? item.name : ""))
|
||||
.with("skills", () => ("name" in item ? item.name : ""))
|
||||
.with("languages", () => ("language" in item ? item.language : ""))
|
||||
.with("interests", () => ("name" in item ? item.name : ""))
|
||||
.with("awards", () => ("title" in item ? item.title : ""))
|
||||
.with("certifications", () => ("title" in item ? item.title : ""))
|
||||
.with("publications", () => ("title" in item ? item.title : ""))
|
||||
.with("volunteer", () => ("organization" in item ? item.organization : ""))
|
||||
.with("references", () => ("name" in item ? item.name : ""))
|
||||
.with("cover-letter", () => {
|
||||
if ("recipient" in item) {
|
||||
const stripped = stripHtml(item.recipient);
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
}
|
||||
return t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
|
||||
return match(type)
|
||||
.with("summary", () => undefined)
|
||||
.with("profiles", () => ("username" in item ? item.username : undefined))
|
||||
.with("experience", () => ("position" in item ? item.position : undefined))
|
||||
.with("education", () => ("degree" in item ? item.degree : undefined))
|
||||
.with("projects", () => ("period" in item ? item.period : undefined))
|
||||
.with("skills", () => ("proficiency" in item ? item.proficiency : undefined))
|
||||
.with("languages", () => ("fluency" in item ? item.fluency : undefined))
|
||||
.with("interests", () => undefined)
|
||||
.with("awards", () => ("awarder" in item ? item.awarder : undefined))
|
||||
.with("certifications", () => ("issuer" in item ? item.issuer : undefined))
|
||||
.with("publications", () => ("publisher" in item ? item.publisher : undefined))
|
||||
.with("volunteer", () => ("period" in item ? item.period : undefined))
|
||||
.with("references", () => undefined)
|
||||
.with("cover-letter", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || undefined;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const customSections = resume.data.customSections;
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add Custom Section Button */}
|
||||
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
|
||||
<Trans>Add a new custom section</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const handleReorder = (items: CustomSectionItemType[]) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
{/* Section Header */}
|
||||
<div className="group flex select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdateSection}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-start transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-md">
|
||||
{getSectionTitle(section.type)}
|
||||
</Badge>
|
||||
<span className="line-clamp-1 text-wrap font-medium text-base">{section.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Plural value={section.items.length} one="# item" other="# items" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<CustomSectionDropdownMenu section={section} />
|
||||
</div>
|
||||
|
||||
{/* Section Items */}
|
||||
{section.items.length > 0 && (
|
||||
<div className={cn("border-t", section.hidden && "opacity-50")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type={section.type}
|
||||
item={item}
|
||||
customSectionId={section.id}
|
||||
title={getItemTitle(section.type, item)}
|
||||
subtitle={getItemSubtitle(section.type, item)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Item Button */}
|
||||
<div className="border-t">
|
||||
<SectionAddItemButton type={section.type} customSectionId={section.id}>
|
||||
<Trans>Add a new item</Trans>
|
||||
</SectionAddItemButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onToggleSectionVisibility = () => {
|
||||
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 onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const onDuplicateSection = () => {
|
||||
openDialog("resume.sections.custom.create", section);
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].columns = Number.parseInt(value, 10);
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteSection = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
|
||||
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
|
||||
...page,
|
||||
main: page.main.filter((id) => id !== section.id),
|
||||
sidebar: page.sidebar.filter((id) => id !== section.id),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleSectionVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onUpdateSection}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDuplicateSection}>
|
||||
<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" onClick={onDeleteSection}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { educationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.education;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,45 @@
|
||||
import type { experienceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.experience;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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 initial={false} mode="popLayout">
|
||||
{section.items.map((item) => {
|
||||
return (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="experience"
|
||||
item={item}
|
||||
title={item.company}
|
||||
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="experience">
|
||||
<Trans>Add a new experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { interestItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.interests;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,36 @@
|
||||
import type { languageItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.languages;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,554 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, TrashSimpleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pictureSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PictureSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="picture">
|
||||
<PictureSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type PictureValues = z.infer<typeof pictureSchema>;
|
||||
|
||||
function normalizePictureUrl(url: string, origin: string): string {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("/uploads/")) return `/api${url}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, origin);
|
||||
if (parsed.origin !== origin) return url;
|
||||
if (!parsed.pathname.startsWith("/uploads/")) return url;
|
||||
return `/api${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function PictureSectionForm() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const picture = resume.data.picture;
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
const [pictureSrc, setPictureSrc] = useState("");
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
|
||||
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
|
||||
|
||||
const persist = (data: PictureValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.picture = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: picture,
|
||||
validators: { onChange: pictureSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const onSelectPicture = () => {
|
||||
if (!fileInputRef.current) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const onDeletePicture = () => {
|
||||
if (!picture.url) return;
|
||||
|
||||
const appOrigin = window.location.origin;
|
||||
const pictureUrl = new URL(picture.url, appOrigin);
|
||||
const pictureOrigin = pictureUrl.origin;
|
||||
|
||||
const filename = pictureUrl.pathname.split("/").pop();
|
||||
if (!filename) return;
|
||||
|
||||
// If the picture is from the same origin, attempt to delete it
|
||||
if (pictureOrigin === appOrigin) deleteFile({ filename });
|
||||
|
||||
form.setFieldValue("url", "");
|
||||
handleAutoSave();
|
||||
};
|
||||
|
||||
const onUploadPicture = (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.setFieldValue("url", url);
|
||||
handleAutoSave();
|
||||
toast.dismiss(toastId);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when uploading profile picture for resume fails",
|
||||
message: "Failed to upload picture. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedPictureUrl) {
|
||||
setPictureSrc("");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let objectUrl = "";
|
||||
|
||||
void fetch(normalizedPictureUrl, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setPictureSrc(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setPictureSrc(normalizedPictureUrl);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [normalizedPictureUrl]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={picture.url ? onDeletePicture : onSelectPicture}
|
||||
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
|
||||
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
{(pictureSrc || normalizedPictureUrl) && (
|
||||
<img
|
||||
alt=""
|
||||
src={pictureSrc || normalizedPictureUrl}
|
||||
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
|
||||
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<form.Field name="url">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => {
|
||||
field.handleChange(event.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
form.setFieldValue("hidden", !picture.hidden);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<form.Field name="size">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={32}
|
||||
max={512}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="rotation">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Rotation</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
max={360}
|
||||
step={5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>°</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="aspectRatio">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Aspect Ratio</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to square",
|
||||
message: "Square",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(1);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to landscape orientation",
|
||||
message: "Landscape",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(1.5);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-1.5/1 min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to portrait orientation",
|
||||
message: "Portrait",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(0.5);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-1/1.5 min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="borderRadius">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Border Radius</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="0pt"
|
||||
onClick={() => {
|
||||
field.handleChange(0);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-none border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="10pt"
|
||||
onClick={() => {
|
||||
field.handleChange(10);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-[10%] border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="100pt"
|
||||
onClick={() => {
|
||||
field.handleChange(100);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-full border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="borderColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="borderWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Border Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="shadowColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="shadowWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Shadow Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { profileItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.profiles;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,41 @@
|
||||
import type { projectItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.projects;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,36 @@
|
||||
import type { publicationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.publications;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,36 @@
|
||||
import type { referenceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.references;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,36 @@
|
||||
import type { skillItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.skills;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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 initial={false} mode="popLayout">
|
||||
{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,21 @@
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SummarySectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.summary;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.summary.content = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="summary">
|
||||
<RichInput value={section.content} onChange={onChange} />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.volunteer;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,74 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume } from "@/components/resume/use-resume";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
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 resume = useCurrentResume();
|
||||
const data = resume.data;
|
||||
const section =
|
||||
type === "basics"
|
||||
? data.basics
|
||||
: type === "summary"
|
||||
? data.summary
|
||||
: type === "picture"
|
||||
? data.picture
|
||||
: type === "custom"
|
||||
? data.customSections
|
||||
: 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
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? [] : [type]}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
className={cn("space-y-4", isHidden && "opacity-50")}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion-item space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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(
|
||||
"p-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type {
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
SectionItem as SectionItemType,
|
||||
SectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { ButtonProps } from "@reactive-resume/ui/components/button";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
CopySimpleIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FolderPlusIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "@/libs/resume/move-item";
|
||||
|
||||
// ============================================================================
|
||||
// MoveItemSubmenu Component
|
||||
// ============================================================================
|
||||
|
||||
type MoveItemSubmenuProps = {
|
||||
type: CustomSectionType;
|
||||
item: CustomSectionItem | SectionItemType;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Submenu component for moving items between sections/pages.
|
||||
* Displays compatible targets grouped by page with options to:
|
||||
* - Move to existing compatible section
|
||||
* - Create new section on existing page
|
||||
* - Create new page with new section
|
||||
*/
|
||||
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
/** Compute compatible move targets grouped by page */
|
||||
const moveTargets = useMemo(
|
||||
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
|
||||
[resume, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Get the current section's title (used when creating new sections) */
|
||||
const currentSectionTitle = useMemo(
|
||||
() => getSourceSectionTitle(resume.data, type, customSectionId),
|
||||
[resume, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Handler: Move item to an existing section */
|
||||
const handleMoveToSection = (targetSectionId: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
addItemToSection(draft, removedItem, targetSectionId, type);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new custom section on an existing page and move the item there */
|
||||
const handleNewSectionOnPage = (pageIndex: number) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createCustomSectionWithItem(draft, removedItem, type, currentSectionTitle, pageIndex);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new page with a new custom section and move the item there */
|
||||
const handleNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createPageWithSection(draft, removedItem, type, currentSectionTitle);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowBendUpRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Render each page as a submenu */}
|
||||
{moveTargets.map(({ pageIndex, sections }) => (
|
||||
<DropdownMenuSub key={pageIndex}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileIcon />
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Existing compatible sections on this page */}
|
||||
{sections.map(({ sectionId, sectionTitle }) => (
|
||||
<DropdownMenuItem key={sectionId} onClick={() => handleMoveToSection(sectionId)}>
|
||||
{sectionTitle}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{/* Separator if there are existing sections */}
|
||||
{sections.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Option to create a new section on this page */}
|
||||
<DropdownMenuItem onClick={() => handleNewSectionOnPage(pageIndex)}>
|
||||
<FolderPlusIcon />
|
||||
<Trans>New Section</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Option to create a new page with a new section */}
|
||||
<DropdownMenuItem onClick={handleNewPage}>
|
||||
<PlusCircleIcon />
|
||||
<Trans>New Page</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SectionItem Component
|
||||
// ============================================================================
|
||||
|
||||
type Props<T extends CustomSectionItem | SectionItemType> = {
|
||||
type: CustomSectionType;
|
||||
item: T;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
type,
|
||||
item,
|
||||
title,
|
||||
subtitle,
|
||||
customSectionId,
|
||||
}: Props<T>) {
|
||||
const confirm = useConfirm();
|
||||
const controls = useDragControls();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
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 = () => {
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a section item in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
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: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="group relative flex h-18 select-none border-b will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 ps-1.5 text-start opacity-100 transition-opacity hover:bg-secondary/40 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 className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleVisibility}>
|
||||
{item.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{item.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type AddButtonProps = Omit<ButtonProps, "type"> & {
|
||||
type: CustomSectionType | "custom";
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (type === "custom") {
|
||||
openDialog("resume.sections.custom.create", undefined);
|
||||
} else {
|
||||
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleAdd}
|
||||
className={cn("h-12 w-full justify-start rounded-t-none", className)}
|
||||
{...props}
|
||||
>
|
||||
<PlusIcon />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
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 "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
|
||||
type Props = {
|
||||
type: "summary" | SectionType;
|
||||
};
|
||||
|
||||
export function SectionDropdownMenu({ type }: Props) {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const resume = useCurrentResume();
|
||||
const section = type === "summary" ? resume.data.summary : 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 = Number.parseInt(value, 10);
|
||||
} else {
|
||||
draft.sections[type].columns = Number.parseInt(value, 10);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
|
||||
description: t`This will remove all items from this section.`,
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when resetting a resume section",
|
||||
message: "Reset",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort resetting a resume section",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.content = "";
|
||||
} else {
|
||||
draft.sections[type].items = [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<ListIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
{type !== "summary" && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onAddItem}>
|
||||
<PlusIcon />
|
||||
<Trans>Add a new item</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={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" onClick={onReset}>
|
||||
<BroomIcon />
|
||||
<Trans>Reset</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
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 { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
|
||||
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("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
.with("statistics", () => <StatisticsSectionBuilder />)
|
||||
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
|
||||
.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)] overflow-hidden bg-background sm:me-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 className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center 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,344 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
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 { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function DesignSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="design" className="space-y-6">
|
||||
<ColorSectionForm />
|
||||
<Separator />
|
||||
<LevelSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorValues = z.infer<typeof colorDesignSchema>;
|
||||
|
||||
function ColorSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: ColorValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.colors = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: colors,
|
||||
validators: { onChange: colorDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="flex flex-wrap gap-2.5 p-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
{quickColorOptions.map((color) => (
|
||||
<QuickColorCircle
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === field.state.value}
|
||||
onSelect={(color) => {
|
||||
field.handleChange(color as string);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Primary Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
value={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="text">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="background">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</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 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LevelValues = z.infer<typeof levelDesignSchema>;
|
||||
type LevelType = LevelValues["type"];
|
||||
|
||||
function LevelSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const levelDesign = resume.data.metadata.design.level;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: LevelValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.level = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: levelDesign,
|
||||
validators: { onChange: levelDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const previewType = useStore(form.store, (s) => s.values.type);
|
||||
const previewIcon = useStore(form.store, (s) => s.values.icon);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<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={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<form.Field name="icon">
|
||||
{(field) => (
|
||||
<FormItem className="shrink-0" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Icon</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker
|
||||
size="icon"
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="type">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<LevelTypeCombobox
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
field.handleChange(value as LevelType);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resumeData = useResume();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const resume = resumeData;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="export" className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadJSON}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start 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"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start 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,106 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/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
|
||||
size="sm"
|
||||
variant="default"
|
||||
nativeButton={false}
|
||||
className="mt-2 whitespace-normal px-4! text-xs"
|
||||
render={
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<HandHeartIcon />
|
||||
<span className="truncate">
|
||||
<Trans>Donate to Reactive Resume</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<Trans>Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Source Code</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener">
|
||||
<Trans>Report a Bug</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Translations</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Sponsors</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { metadataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
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 resume = useCurrentResume();
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.sidebarWidth = data.sidebarWidth;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { sidebarWidth: layout.sidebarWidth },
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="sidebarWidth">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Sidebar Width</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.01}
|
||||
value={[field.state.value]}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(Array.isArray(value) ? value[0] : value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroup className="w-auto shrink-0">
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>%</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
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 { forwardRef, useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
|
||||
type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the primary content area",
|
||||
message: "Main",
|
||||
}),
|
||||
)
|
||||
.with("sidebar", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the secondary sidebar area",
|
||||
message: "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 resume = useCurrentResume();
|
||||
const template = resume.data.metadata.template;
|
||||
const templateSidebarPosition = templates[template].sidebarPosition;
|
||||
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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];
|
||||
// Find the first available page that isn't being deleted
|
||||
const targetPageIndex = pageIndex === 0 ? 1 : 0;
|
||||
const targetPage = draft.metadata.layout.pages[targetPageIndex];
|
||||
|
||||
// Move all sections from deleted page to target page
|
||||
targetPage.main.push(...pageToDelete.main);
|
||||
targetPage.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
|
||||
id="builder-layout"
|
||||
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={`page-${pageIndex}`}
|
||||
pageIndex={pageIndex}
|
||||
page={page}
|
||||
canDelete={layout.pages.length > 1}
|
||||
sidebarPosition={templateSidebarPosition}
|
||||
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;
|
||||
sidebarPosition: "left" | "right" | "none";
|
||||
onDelete: (pageIndex: number) => void;
|
||||
onToggleFullWidth: (pageIndex: number, fullWidth: boolean) => void;
|
||||
};
|
||||
|
||||
function PageContainer({
|
||||
pageIndex,
|
||||
page,
|
||||
canDelete,
|
||||
sidebarPosition,
|
||||
onDelete,
|
||||
onToggleFullWidth,
|
||||
}: PageContainerProps) {
|
||||
const isFullWidth = page.fullWidth;
|
||||
const fullWidthSwitchId = useId();
|
||||
|
||||
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 comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">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 @md:grid-cols-2 gap-x-4 gap-y-2 p-4 pt-0 font-medium",
|
||||
sidebarPosition === "none" && "@md:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="main"
|
||||
items={page.main}
|
||||
disabled={false}
|
||||
className={cn(sidebarPosition === "left" ? "order-2" : "order-1")}
|
||||
/>
|
||||
|
||||
{!isFullWidth && (
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="sidebar"
|
||||
items={page.sidebar}
|
||||
hideLabel={sidebarPosition === "none"}
|
||||
className={cn(sidebarPosition === "left" ? "order-1" : "order-2")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutColumnProps = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
items: string[];
|
||||
hideLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function LayoutColumn({
|
||||
pageIndex,
|
||||
columnId,
|
||||
items,
|
||||
hideLabel = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: LayoutColumnProps) {
|
||||
const droppableId = createDroppableId(pageIndex, columnId);
|
||||
const { setNodeRef, isOver } = useDroppable({ id: droppableId, disabled });
|
||||
|
||||
return (
|
||||
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn("space-y-1.5", disabled && "opacity-50", className)}>
|
||||
{!hideLabel && <div className="@md:row-start-1 ps-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 resume = useCurrentResume();
|
||||
const title = (() => {
|
||||
if (!resume) return id;
|
||||
if (id === "summary") return resume.data.summary.title || getSectionTitle("summary");
|
||||
if (id in resume.data.sections)
|
||||
return resume.data.sections[id as SectionType].title || getSectionTitle(id as SectionType);
|
||||
const customSection = 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/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LayoutItemContent.displayName = "LayoutItemContent";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function NotesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="notes">
|
||||
<NotesSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const notes = resume.data.metadata.notes;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
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,283 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { pageSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { getLocaleOptions } from "@/components/locale/combobox";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
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 resume = useResume();
|
||||
const page = resume?.data.metadata.page;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.page = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: page,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = <K extends keyof FormValues>(name: K, value: FormValues[K]) => {
|
||||
persist({ ...form.state.values, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="locale">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={getLocaleOptions()}
|
||||
value={field.state.value}
|
||||
onValueChange={(locale) => {
|
||||
const value = (locale ?? "") as string;
|
||||
field.handleChange(value);
|
||||
handleAutoSave("locale", value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="format">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans context="Page Format (A4, Letter)">Format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "a4", label: t`A4` },
|
||||
{ value: "letter", label: t`Letter` },
|
||||
]}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
const format = value as FormValues["format"];
|
||||
field.handleChange(format);
|
||||
handleAutoSave("format", format);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginX);
|
||||
handleAutoSave("marginX", marginX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginY);
|
||||
handleAutoSave("marginY", marginY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapX);
|
||||
handleAutoSave("gapX", gapX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapY);
|
||||
handleAutoSave("gapY", gapY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="hideIcons">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full flex items-center gap-x-3 py-2"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Switch
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked);
|
||||
handleAutoSave("hideIcons", checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hide all icons on the resume</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useAIStore } from "@reactive-resume/ai/store";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
function impactCircleClass(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () => "bg-rose-600")
|
||||
.with("medium", () => "bg-amber-600")
|
||||
.with("low", () => "bg-emerald-600")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function impactLabel(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "High",
|
||||
}),
|
||||
)
|
||||
.with("medium", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Medium",
|
||||
}),
|
||||
)
|
||||
.with("low", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Low",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function ResumeAnalysisSectionBuilder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const resume = useResume();
|
||||
const aiEnabled = useAIStore((state) => state.enabled);
|
||||
const aiProvider = useAIStore((state) => state.provider);
|
||||
const aiModel = useAIStore((state) => state.model);
|
||||
const aiApiKey = useAIStore((state) => state.apiKey);
|
||||
const aiBaseURL = useAIStore((state) => state.baseURL);
|
||||
|
||||
const resumeId = resume?.id ?? "";
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
|
||||
enabled: !!resume,
|
||||
});
|
||||
|
||||
const { mutate: analyzeResume, isPending } = useMutation({
|
||||
...orpc.ai.analyzeResume.mutationOptions(),
|
||||
onSuccess: (analysis) => {
|
||||
queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resumeId } }), analysis);
|
||||
toast.success(t`Resume analysis complete.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to analyze resume.`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI returns invalid resume analysis format",
|
||||
message: "The AI returned an invalid analysis format. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider cannot be reached during resume analysis",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when resume analysis request fails",
|
||||
message: "Something went wrong while analyzing your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const analysis = analysisQuery.data;
|
||||
const score = analysis?.overallScore ?? null;
|
||||
const analyzeLabel = isPending ? t`Analyzing...` : t`Analyze Resume`;
|
||||
|
||||
const scoreTone = useMemo(() => {
|
||||
if (score == null) return "bg-muted";
|
||||
if (score >= 80) return "bg-emerald-600";
|
||||
if (score >= 60) return "bg-amber-600";
|
||||
return "bg-rose-600";
|
||||
}, [score]);
|
||||
|
||||
const onAnalyze = () => {
|
||||
if (!resume) return;
|
||||
|
||||
analyzeResume({
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
apiKey: aiApiKey,
|
||||
baseURL: aiBaseURL,
|
||||
resumeId: resume.id,
|
||||
resumeData: resume.data,
|
||||
});
|
||||
};
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="analysis" className="space-y-4">
|
||||
{!aiEnabled && <DisabledState />}
|
||||
|
||||
{aiEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 rounded-md border bg-card p-3">
|
||||
<div className="grid grid-cols-2 items-center gap-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Get a review of your resume with an overall score, strengths, and actionable suggestions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button disabled={isPending} onClick={onAnalyze} className="ml-auto w-fit">
|
||||
<SparkleIcon />
|
||||
{analyzeLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<div
|
||||
className={`grid size-18 place-items-center rounded-full border-3 border-background font-bold text-lg text-white ${scoreTone}`}
|
||||
>
|
||||
{score ?? "--"}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>Overall Score</Trans>
|
||||
</p>
|
||||
<div className="grid grid-cols-10 gap-1">
|
||||
{Array.from({ length: 10 }).map((_, index) => {
|
||||
const active = score != null && index < Math.round(score / 10);
|
||||
return (
|
||||
<div
|
||||
key={`scorebar-${index}`}
|
||||
className={`h-1.5 rounded-full transition-colors ${active ? "bg-primary" : "bg-muted"}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{analysis?.updatedAt && (
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
<Trans>Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisQuery.isFetched && !analysis && !isPending && (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="max-w-xs text-muted-foreground text-sm">
|
||||
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="flex items-center gap-2 font-semibold text-sm">
|
||||
<LightningIcon className="text-primary" />
|
||||
<Trans>Scorecard</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.scorecard.map((item) => (
|
||||
<div key={item.dimension} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-sm">{item.dimension}</div>
|
||||
<Badge variant="secondary">{item.score}/100</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{item.rationale}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Strengths</Trans>
|
||||
</h5>
|
||||
|
||||
<ul className="list-outside list-disc pl-5 text-muted-foreground text-sm">
|
||||
{analysis.strengths.map((strength) => (
|
||||
<li key={strength} className="py-1.5">
|
||||
{strength}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<div className="space-y-4 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Suggestions</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.suggestions.map((suggestion) => (
|
||||
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">{suggestion.why}</div>
|
||||
|
||||
{suggestion.exampleRewrite && (
|
||||
<div className="rounded bg-muted p-2 text-muted-foreground text-xs">
|
||||
{suggestion.exampleRewrite}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
<Trans>
|
||||
Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
|
||||
suggestions. To activate this feature, please update your AI settings.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/settings/integrations">
|
||||
<Trans>Open Integrations Settings</Trans>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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 } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/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 resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
|
||||
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 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 {
|
||||
const updated = await updateResume({ id: resume.id, isPublic: checked });
|
||||
patchResume((draft) => {
|
||||
draft.isPublic = updated.isPublic;
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[patchResume, 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 });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = true;
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}, [patchResume, 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 });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = false;
|
||||
});
|
||||
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, patchResume, removePassword, resume.hasPassword, resume.id]);
|
||||
|
||||
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">
|
||||
<span className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</span>
|
||||
|
||||
<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">
|
||||
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
|
||||
</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 "@reactive-resume/ui/components/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { orpc } from "@/libs/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 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,62 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useCurrentResume } from "@/components/resume/use-resume";
|
||||
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 resume = useCurrentResume();
|
||||
const template = 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"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<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-2.5">
|
||||
{metadata.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { typographySchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TypographySectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="typography">
|
||||
<TypographySectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = typographySchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FontWeight = FormValues["body"]["fontWeights"][number];
|
||||
|
||||
function TypographySectionForm() {
|
||||
const resume = useResume();
|
||||
const typography = resume?.data.metadata.typography;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.typography.body = data.body;
|
||||
draft.metadata.typography.heading = data.heading;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: typography,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const bodyFontFamily = useStore(form.store, (s) => s.values.body.fontFamily);
|
||||
const headingFontFamily = useStore(form.store, (s) => s.values.heading.fontFamily);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="body.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("body.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weights</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={bodyFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="heading.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("heading.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weight</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={headingFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
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
|
||||
className="space-y-4"
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? [] : [type]}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion-item space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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,55 @@
|
||||
import type { SidebarSection } from "@/libs/resume/section";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { leftSidebarSections, rightSidebarSections } from "@/libs/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,139 @@
|
||||
import type { Layout, usePanelRef } from "react-resizable-panels";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { create } from "zustand/react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
export type BuilderLayout = {
|
||||
left: number;
|
||||
artboard: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
|
||||
left: 22,
|
||||
artboard: 56,
|
||||
right: 22,
|
||||
};
|
||||
|
||||
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
|
||||
const left = layout.left;
|
||||
const artboard = layout.artboard;
|
||||
const right = layout.right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
};
|
||||
|
||||
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
|
||||
if (!value) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
|
||||
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
const left = (parsed as { left?: unknown }).left;
|
||||
const artboard = (parsed as { artboard?: unknown }).artboard;
|
||||
const right = (parsed as { right?: unknown }).right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
} catch {
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
}
|
||||
};
|
||||
|
||||
interface BuilderSidebarState {
|
||||
layout: BuilderLayout;
|
||||
leftSidebar: PanelImperativeHandle | null;
|
||||
rightSidebar: PanelImperativeHandle | null;
|
||||
}
|
||||
|
||||
interface BuilderSidebarActions {
|
||||
setLayout: (layout: BuilderLayout) => void;
|
||||
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
}
|
||||
|
||||
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
|
||||
|
||||
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
|
||||
layout: DEFAULT_BUILDER_LAYOUT,
|
||||
leftSidebar: null,
|
||||
rightSidebar: null,
|
||||
setLayout: (layout) => set({ layout }),
|
||||
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,6 @@
|
||||
import { createFileRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId/")({
|
||||
component: lazyRouteComponent(() => import("./-components/preview-page"), "PreviewPage"),
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type React from "react";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
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 { useEffect, useRef } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import {
|
||||
useInitializeResumeStore,
|
||||
useMergeResumeMetadata,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
} from "@/components/resume/use-resume";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
import {
|
||||
BUILDER_LAYOUT_COOKIE_NAME,
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
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 initializeResumeStore = useInitializeResumeStore();
|
||||
const mergeResumeMetadata = useMergeResumeMetadata();
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
|
||||
useResumeCleanup();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
initializeResumeStore(resume);
|
||||
}, [initializeResumeStore, isInitialized, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
mergeResumeMetadata(resume);
|
||||
}, [
|
||||
mergeResumeMetadata,
|
||||
resume.id,
|
||||
resume.name,
|
||||
resume.slug,
|
||||
resume.tags,
|
||||
resume.isLocked,
|
||||
resume.isPublic,
|
||||
resume.hasPassword,
|
||||
resume,
|
||||
]);
|
||||
|
||||
if (!isInitialized) return null;
|
||||
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
|
||||
maxSidebarSize: state.maxSidebarSize,
|
||||
collapsedSidebarSize: state.collapsedSidebarSize,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(initialLayout);
|
||||
canPersistLayoutRef.current = true;
|
||||
}, [initialLayout, setLayout]);
|
||||
|
||||
const onLayoutChanged = (layout: Layout) => {
|
||||
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
|
||||
if (!canPersistLayoutRef.current) return;
|
||||
setLayout(nextLayout);
|
||||
void setBuilderLayoutServerFn({ data: nextLayout });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
|
||||
setLeftSidebar(leftSidebarRef);
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
|
||||
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
|
||||
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">
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarLeft />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-s" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-e" />
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
|
||||
});
|
||||
|
||||
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
|
||||
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return DEFAULT_BUILDER_LAYOUT;
|
||||
return parseBuilderLayoutCookie(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 "@reactive-resume/ui/components/sidebar";
|
||||
import { cn } from "@reactive-resume/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 inset-s-0 md:hidden" />
|
||||
<IconComponent weight="light" className="size-5" />
|
||||
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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 "@reactive-resume/ui/components/avatar";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
useSidebarState,
|
||||
} from "@reactive-resume/ui/components/sidebar";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
|
||||
|
||||
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`Integrations`,
|
||||
href: "/dashboard/settings/integrations",
|
||||
},
|
||||
{
|
||||
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
|
||||
title={i18n.t(item.label)}
|
||||
render={
|
||||
<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]:-ms-8 group-data-[collapsible=icon]:opacity-0">
|
||||
{i18n.t(item.label)}
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const { state } = useSidebarState();
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="h-auto justify-center"
|
||||
render={
|
||||
<Link to="/">
|
||||
<BrandIcon variant="icon" className="size-6" />
|
||||
<h1 className="sr-only">Reactive Resume</h1>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</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]:-ms-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"
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Copyright className="wrap-break-word shrink-0 whitespace-normal 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 { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { CometCard } from "@/components/animation/comet-card";
|
||||
|
||||
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,62 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { ResumeContextMenu } from "../menus/context-menu";
|
||||
import { BaseCard } from "./base-card";
|
||||
import { ResumeThumbnail } from "./resume-thumbnail";
|
||||
|
||||
type ResumeCardProps = {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
};
|
||||
|
||||
export function ResumeCard({ resume }: ResumeCardProps) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const updatedAt = useMemo(() => {
|
||||
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
|
||||
}, [i18n.locale, resume.updatedAt]);
|
||||
|
||||
return (
|
||||
<ResumeContextMenu resume={resume}>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -2, scale: 1.005 }}
|
||||
whileTap={{ scale: 0.998 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
>
|
||||
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
|
||||
<ResumeThumbnail resume={resume} isLocked={resume.isLocked} />
|
||||
|
||||
<ResumeLockOverlay isLocked={resume.isLocked} />
|
||||
</BaseCard>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</ResumeContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isLocked && (
|
||||
<motion.div
|
||||
key="resume-lock-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute inset-0 flex items-center justify-center will-change-[opacity]"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-popover p-6">
|
||||
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
type PageSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const RESUME_THUMBNAIL_TARGET_WIDTH = 420;
|
||||
const MAX_THUMBNAIL_PIXEL_RATIO = 2;
|
||||
|
||||
export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => {
|
||||
return `${resumeId}:${updatedAt.getTime()}`;
|
||||
};
|
||||
|
||||
export const getResumeThumbnailRenderSize = (
|
||||
pageSize: PageSize,
|
||||
targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
pixelRatio = 1,
|
||||
) => {
|
||||
const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO);
|
||||
const pageScale = targetWidth / pageSize.width;
|
||||
|
||||
return {
|
||||
height: Math.round(pageSize.height * pageScale * outputScale),
|
||||
scale: pageScale * outputScale,
|
||||
width: Math.round(targetWidth * outputScale),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { FileTextIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInView } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import {
|
||||
getResumeThumbnailCacheKey,
|
||||
getResumeThumbnailRenderSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
} from "./resume-thumbnail.shared";
|
||||
|
||||
type ResumeListItem = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type ThumbnailState = { status: "error" | "idle" | "loading" } | { status: "ready"; url: string };
|
||||
|
||||
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to create resume thumbnail image."));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
}, "image/png");
|
||||
});
|
||||
};
|
||||
|
||||
const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist");
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
|
||||
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
|
||||
|
||||
try {
|
||||
pdfDocument = await loadingTask.promise;
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
try {
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const renderSize = getResumeThumbnailRenderSize(
|
||||
{ height: baseViewport.height, width: baseViewport.width },
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvasContext = canvas.getContext("2d");
|
||||
|
||||
if (!canvasContext) throw new Error("Failed to create resume thumbnail canvas context.");
|
||||
|
||||
canvas.height = renderSize.height;
|
||||
canvas.width = renderSize.width;
|
||||
|
||||
const viewport = page.getViewport({ scale: renderSize.scale });
|
||||
const renderTask = page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
annotationMode: AnnotationMode.DISABLE,
|
||||
background: "white",
|
||||
});
|
||||
|
||||
await renderTask.promise;
|
||||
|
||||
const image = await canvasToBlob(canvas);
|
||||
return URL.createObjectURL(image);
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
} finally {
|
||||
if (pdfDocument) {
|
||||
void pdfDocument.destroy();
|
||||
} else {
|
||||
void loadingTask.destroy();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined) {
|
||||
const [thumbnail, setThumbnail] = useState<ThumbnailState>({ status: "idle" });
|
||||
const currentUrlRef = useRef<string | null>(null);
|
||||
|
||||
const revokeCurrentThumbnail = useCallback(() => {
|
||||
if (!currentUrlRef.current) return;
|
||||
URL.revokeObjectURL(currentUrlRef.current);
|
||||
currentUrlRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revokeCurrentThumbnail();
|
||||
};
|
||||
}, [revokeCurrentThumbnail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !cacheKey) {
|
||||
revokeCurrentThumbnail();
|
||||
setThumbnail({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let nextUrl: string | null = null;
|
||||
|
||||
setThumbnail({ status: "loading" });
|
||||
|
||||
const generateThumbnail = async () => {
|
||||
try {
|
||||
const pdf = await createResumePdfBlob(data);
|
||||
if (isCancelled) return;
|
||||
|
||||
nextUrl = await createPdfFirstPageImageUrl(pdf);
|
||||
if (isCancelled) {
|
||||
URL.revokeObjectURL(nextUrl);
|
||||
nextUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
revokeCurrentThumbnail();
|
||||
currentUrlRef.current = nextUrl;
|
||||
setThumbnail({ status: "ready", url: nextUrl });
|
||||
nextUrl = null;
|
||||
} catch (error) {
|
||||
if (isCancelled) return;
|
||||
|
||||
console.error("Failed to generate resume thumbnail", error);
|
||||
revokeCurrentThumbnail();
|
||||
setThumbnail({ status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
void generateThumbnail();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
|
||||
if (nextUrl) {
|
||||
URL.revokeObjectURL(nextUrl);
|
||||
}
|
||||
};
|
||||
}, [data, cacheKey, revokeCurrentThumbnail]);
|
||||
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
export function ResumeThumbnail({ isLocked, resume }: { isLocked: boolean; resume: ResumeListItem }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const resumeQuery = useQuery({
|
||||
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
|
||||
enabled: isInView,
|
||||
});
|
||||
const thumbnail = useResumeThumbnail(
|
||||
resumeQuery.data?.data,
|
||||
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
|
||||
);
|
||||
const hasFailed = resumeQuery.isError || thumbnail.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative size-full overflow-hidden bg-muted/40 transition-all", isLocked && "blur-xs")}
|
||||
>
|
||||
{thumbnail.status === "ready" ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${thumbnail.url})` }}
|
||||
/>
|
||||
) : hasFailed ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FileTextIcon weight="thin" className="size-12 opacity-40" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { CreateResumeCard } from "./cards/create-card";
|
||||
import { ImportResumeCard } from "./cards/import-card";
|
||||
import { ResumeCard } from "./cards/resume-card";
|
||||
|
||||
type Resume = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type Props = {
|
||||
resumes: Resume[];
|
||||
};
|
||||
|
||||
export function GridView({ resumes }: Props) {
|
||||
return (
|
||||
<div className="grid 3xl:grid-cols-6 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
filter: "blur(8px)",
|
||||
}}
|
||||
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
className="will-change-[transform,opacity]"
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsThreeIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { ResumeDropdownMenu } from "./menus/dropdown-menu";
|
||||
|
||||
type Resume = RouterOutput["resume"]["list"][number];
|
||||
|
||||
type Props = {
|
||||
resumes: Resume[];
|
||||
};
|
||||
|
||||
export function ListView({ resumes }: Props) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleCreateResume = () => {
|
||||
openDialog("resume.create", undefined);
|
||||
};
|
||||
|
||||
const handleImportResume = () => {
|
||||
openDialog("resume.import", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleCreateResume}
|
||||
>
|
||||
<PlusIcon />
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Create a new resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Start building your resume from scratch</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="h-12 w-full justify-start gap-x-4 text-start"
|
||||
onClick={handleImportResume}
|
||||
>
|
||||
<DownloadSimpleIcon />
|
||||
|
||||
<div className="min-w-80 truncate">
|
||||
<Trans>Import an existing resume</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Continue where you left off</Trans>
|
||||
</p>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{resumes?.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
className="will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeListItem({ resume }: { resume: Resume }) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const updatedAt = useMemo(() => {
|
||||
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
|
||||
}, [i18n.locale, resume.updatedAt]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
nativeButton={false}
|
||||
className="h-12 w-full flex-1 justify-start gap-x-4 text-start"
|
||||
render={
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<div className="size-3" />
|
||||
<div className="min-w-80 truncate">{resume.name}</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
<Trans>Last updated on {updatedAt}</Trans>
|
||||
</p>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResumeDropdownMenu resume={resume} align="end">
|
||||
<Button size="icon" variant="ghost" className="size-12">
|
||||
<DotsThreeIcon />
|
||||
</Button>
|
||||
</ResumeDropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CopySimpleIcon,
|
||||
FolderOpenIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/context-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type Props = {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
children: React.ComponentProps<typeof ContextMenuTrigger>["render"];
|
||||
};
|
||||
|
||||
export function ResumeContextMenu({ resume, children }: Props) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
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(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={children} />
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
render={
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<FolderOpenIcon />
|
||||
<Trans comment="Resume card context menu action to open the resume editor">Open</Trans>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans comment="Resume card context menu action to create a copy">Duplicate</Trans>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card context menu action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card context menu action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans comment="Resume card context menu destructive action to remove a resume">Delete</Trans>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CopySimpleIcon,
|
||||
FolderOpenIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
|
||||
resume: RouterOutput["resume"]["list"][number];
|
||||
children: React.ComponentProps<typeof DropdownMenuTrigger>["render"];
|
||||
};
|
||||
|
||||
export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
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(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={children} />
|
||||
|
||||
<DropdownMenuContent {...props}>
|
||||
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
|
||||
<DropdownMenuItem>
|
||||
<FolderOpenIcon />
|
||||
<Trans comment="Resume card dropdown action to open the resume editor">Open</Trans>
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans comment="Resume card dropdown action to create a copy">Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
|
||||
{resume.isLocked ? (
|
||||
<Trans comment="Resume card dropdown action to remove edit lock">Unlock</Trans>
|
||||
) : (
|
||||
<Trans comment="Resume card dropdown action to prevent edits">Lock</Trans>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans comment="Resume card dropdown destructive action to remove a resume">Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GridFourIcon, ListIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { useMemo } from "react";
|
||||
import z from "zod";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
import { GridView } from "./-components/grid-view";
|
||||
import { ListView } from "./-components/list-view";
|
||||
|
||||
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
|
||||
|
||||
const searchSchema = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
|
||||
view: z.enum(["grid", "list"]).default("grid"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/dashboard/resumes/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(searchSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ tags: [], sort: "lastUpdatedAt", view: "grid" })],
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { tags, sort, view } = 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 gap-x-4">
|
||||
<div className="flex gap-2">
|
||||
<Label>
|
||||
<Trans>Sort by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
value={sort}
|
||||
options={sortOptions}
|
||||
placeholder={t`Sort by`}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
void navigate({ search: { tags, sort: value as SortOption } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex gap-2", { hidden: tagOptions.length === 0 })}>
|
||||
<Label>
|
||||
<Trans>Filter by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
multiple
|
||||
value={tags}
|
||||
options={tagOptions}
|
||||
placeholder={t`Filter by`}
|
||||
onValueChange={(value) => {
|
||||
void navigate({ search: { tags: value ?? [], sort } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs className="ltr:ms-auto rtl:me-auto" value={view}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="grid"
|
||||
nativeButton={false}
|
||||
className="rounded-r-none"
|
||||
render={<Link to="." search={{ view: "grid" }} />}
|
||||
>
|
||||
<GridFourIcon />
|
||||
<Trans>Grid</Trans>
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="list"
|
||||
nativeButton={false}
|
||||
className="rounded-l-none"
|
||||
render={<Link to="." search={{ view: "list" }} />}
|
||||
>
|
||||
<ListIcon />
|
||||
<Trans>List</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{view === "list" ? <ListView resumes={resumes ?? []} /> : <GridView resumes={resumes ?? []} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
|
||||
import { SidebarProvider } from "@reactive-resume/ui/components/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 = async (open: boolean) => {
|
||||
await setDashboardSidebarServerFn({ data: open });
|
||||
void router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider open={sidebarState} onOpenChange={handleSidebarOpenChange}>
|
||||
<DashboardSidebar />
|
||||
|
||||
<main className="@container flex-1 p-4 md:ps-2">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings/ai")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard/settings/integrations", replace: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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 "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
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.apiKeys
|
||||
.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({
|
||||
comment: "API key deletion confirmation dialog confirm action in settings",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "API key deletion confirmation dialog cancel action in settings",
|
||||
message: "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(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when deleting an API key fails",
|
||||
message: "Failed to delete the API key. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`The API key has been deleted successfully.`, { id: toastId });
|
||||
void 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.25, ease: "easeOut" }}
|
||||
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
|
||||
>
|
||||
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
|
||||
<div className="rounded-md 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
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me/api-reference" target="_blank" rel="noopener noreferrer">
|
||||
<LinkSimpleIcon />
|
||||
<Trans>API Reference</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</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 initial={false} mode="popLayout">
|
||||
{apiKeys.map((key, index) => (
|
||||
<motion.div
|
||||
key={key.id}
|
||||
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={{ y: -1, scale: 1.03 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.14, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AuthProvider } from "@reactive-resume/auth/types";
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
FingerprintIcon,
|
||||
GithubLogoIcon,
|
||||
GoogleLogoIcon,
|
||||
LinkedinLogoIcon,
|
||||
PasswordIcon,
|
||||
VaultIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
/**
|
||||
* Get the display name for a social provider
|
||||
*/
|
||||
export function getProviderName(providerId: AuthProvider): string {
|
||||
return match(providerId)
|
||||
.with("credential", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Password",
|
||||
}),
|
||||
)
|
||||
.with("passkey", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Passkey",
|
||||
}),
|
||||
)
|
||||
.with("google", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Google",
|
||||
}),
|
||||
)
|
||||
.with("github", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "GitHub",
|
||||
}),
|
||||
)
|
||||
.with("linkedin", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "LinkedIn",
|
||||
}),
|
||||
)
|
||||
.with("custom", () =>
|
||||
t({
|
||||
comment: "Authentication provider display name in account settings",
|
||||
message: "Custom OAuth",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon component for a social provider
|
||||
*/
|
||||
export function getProviderIcon(providerId: AuthProvider): ReactNode {
|
||||
return match(providerId)
|
||||
.with("credential", () => <PasswordIcon />)
|
||||
.with("passkey", () => <FingerprintIcon />)
|
||||
.with("google", () => <GoogleLogoIcon />)
|
||||
.with("github", () => <GithubLogoIcon />)
|
||||
.with("linkedin", () => <LinkedinLogoIcon />)
|
||||
.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(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when linking a social authentication provider fails",
|
||||
message: "Failed to link provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ 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(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when unlinking a social authentication provider fails",
|
||||
message: "Failed to unlink provider. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
}, []);
|
||||
|
||||
return { link, unlink };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get enabled social providers for the current user
|
||||
* Possible values: "credential", "google", "github", "linkedin", "custom"
|
||||
*/
|
||||
export function useEnabledProviders() {
|
||||
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
return { enabledProviders };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user