Squashed commit of the following:

commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
This commit is contained in:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
@@ -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>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Outlet } from "@tanstack/react-router";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
export function AuthLayout() {
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,141 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon } from "@phosphor-icons/react";
import { Link } 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";
const formSchema = z.object({
email: z.email(),
});
export function ForgotPasswordPage() {
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>
}
/>
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
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 { Link, 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";
const formSchema = z.object({
identifier: z.string().trim().toLowerCase(),
password: z.string().trim().min(6).max(64),
});
type Props = {
disableEmailAuth: boolean;
disableSignups: boolean;
};
export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
const router = useRouter();
const navigate = useNavigate();
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>
{!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>
{!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,282 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { Link } 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";
const formSchema = z.object({
name: z.string().min(3).max(64),
username: z
.string()
.min(3)
.max(64)
.trim()
.toLowerCase()
.regex(/^[a-z0-9._-]+$/, {
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
}),
email: z.email().toLowerCase(),
password: z.string().min(6).max(64),
});
type Props = {
disableEmailAuth: boolean;
};
export function RegisterPage({ disableEmailAuth }: Props) {
const [submitted, setSubmitted] = useState(false);
const [showPassword, toggleShowPassword] = useToggle(false);
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>
{!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,126 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
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 formSchema = z.object({
password: z.string().min(6).max(64),
});
type Props = {
token: string;
};
export function ResetPasswordPage({ token }: Props) {
const navigate = useNavigate();
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,154 @@
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 { useNavigate } from "@tanstack/react-router";
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 formSchema = z.object({
password: z.string().min(6).max(64),
});
type Props = {
redirectPath: string;
};
export function ResumePasswordPage({ redirectPath }: Props) {
const navigate = useNavigate();
const [showPassword, toggleShowPassword] = useToggle(false);
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
const [username, slug] = useMemo(() => {
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
if (!username || !slug) throw navigate({ to: "/" });
return [username, slug];
}, [redirectPath, 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: redirectPath, 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,111 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { Link, 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";
const formSchema = z.object({
code: z.string().trim(),
});
export function VerifyTwoFactorBackupPage() {
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,126 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { Link, 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";
const formSchema = z.object({
code: z.string().length(6, "Code must be 6 digits"),
});
export function VerifyTwoFactorPage() {
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,143 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkeys } from "@tanstack/react-hotkeys";
import { useRef } from "react";
import { Command, CommandEmpty, CommandInput, CommandList } from "@reactive-resume/ui/components/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { NavigationCommandGroup } from "./pages/navigation";
import { PreferencesCommandGroup } from "./pages/preferences";
import { ResumesCommandGroup } from "./pages/resumes";
import { useCommandPaletteStore } from "./store";
export function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
const isFirstPage = pages.length === 0;
const currentPage = pages[pages.length - 1];
// Toggle command palette with Cmd+K / Ctrl+K
useHotkeys([
{
hotkey: "Mod+K",
callback: () => {
setOpen(!open);
},
},
{
hotkey: "Escape",
callback: () => {
if (!open) return;
setOpen(false);
},
},
{
hotkey: "Backspace",
callback: (event) => {
// Only handle if the command palette is open
if (!open) return;
const input = inputRef.current;
if (!input) return;
// Only handle if input is focused
if (document.activeElement !== input) return;
// If input has text, let the default behavior handle it (delete character)
if (search.length > 0) return;
// If input is empty, prevent default and go back
event.preventDefault();
goBack();
},
},
]);
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
const handleSearchChange = (value: string) => {
setSearch(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only print:hidden">
<DialogTitle>
<Trans comment="Screen-reader dialog title for the command palette in the resume builder">
Builder Command Palette
</Trans>
</DialogTitle>
<DialogDescription>
<Trans comment="Screen-reader dialog description instructing users how to use the command palette">
Type a command or search...
</Trans>
</DialogDescription>
</DialogHeader>
<DialogContent
className="overflow-hidden p-0"
aria-label={
isFirstPage
? t({
comment: "Accessible label for the command palette dialog",
message: "Command Palette",
})
: t({
comment: "Accessible label for command palette dialog when browsing a nested command page",
message: `Command Palette - ${currentPage}`,
})
}
>
<Command
loop
aria-label={t({
comment: "Accessible label for command list region inside command palette",
message: "Command Palette",
})}
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
>
<CommandInput
ref={inputRef}
value={search}
onValueChange={handleSearchChange}
placeholder={
isFirstPage
? t({
comment: "Placeholder in command palette input on root page",
message: "Type a command or search...",
})
: t({
comment: "Placeholder in command palette input on nested pages",
message: "Search...",
})
}
aria-label={t({
comment: "Accessible label for command palette search input",
message: "Search commands",
})}
/>
<CommandList>
<CommandEmpty>
<Trans comment="Empty-state message when no command palette results match the search query">
The command you're looking for doesn't exist.
</Trans>
</CommandEmpty>
<ResumesCommandGroup />
<PreferencesCommandGroup />
<NavigationCommandGroup />
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
const renderInCommand = (ui: React.ReactNode) => render(<Command>{ui}</Command>);
const resetStore = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(resetStore);
describe("BaseCommandGroup", () => {
it("renders children at the root (no page prop) when the page stack is empty", () => {
renderInCommand(<BaseCommandGroup heading="Root">child-text</BaseCommandGroup>);
expect(screen.getByText("child-text")).toBeInTheDocument();
});
it("does NOT render when the page stack tops a different page than its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["other"] });
const { container } = renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
<span>x</span>
</BaseCommandGroup>,
);
// Nothing rendered besides the Command shell itself.
expect(container.textContent).toBe("");
});
it("renders when the top of the page stack matches its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
settings-children
</BaseCommandGroup>,
);
expect(screen.getByText("settings-children")).toBeInTheDocument();
});
it("does NOT render the root group when there is a sub-page on top", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
const { container } = renderInCommand(
<BaseCommandGroup heading="Root">
<span>root-text</span>
</BaseCommandGroup>,
);
expect(container.textContent).toBe("");
});
});
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { CommandGroup } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
type Props = {
page?: string;
heading: React.ReactNode;
children: React.ReactNode;
};
export const BaseCommandGroup = ({ page, heading, children }: Props) => {
const pages = useCommandPaletteStore((state) => state.pages);
const currentPage = pages[pages.length - 1];
const isEnabled = useMemo(() => {
return currentPage === page;
}, [currentPage, page]);
if (!isEnabled) return null;
return <CommandGroup heading={heading}>{children}</CommandGroup>;
};
@@ -0,0 +1,126 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
OpenAiLogoIcon,
ReadCvLogoIcon,
ShieldCheckIcon,
UserCircleIcon,
WarningIcon,
} from "@phosphor-icons/react";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
export function NavigationCommandGroup() {
const navigate = useNavigate();
const { session } = useRouteContext({ strict: false });
const reset = useCommandPaletteStore((state) => state.reset);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
const onNavigate = async (path: string) => {
await navigate({ to: path });
reset();
};
return (
<>
<BaseCommandGroup heading={<Trans>Go to...</Trans>}>
<CommandItem keywords={[t`Home`]} value="navigation.home" onSelect={() => onNavigate("/")}>
<HouseSimpleIcon />
<Trans>Home</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Resumes`]}
value="navigation.resumes"
onSelect={() => onNavigate("/dashboard/resumes")}
>
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Settings`]}
value="navigation.settings"
onSelect={() => pushPage("settings")}
>
<GearIcon />
<Trans>Settings</Trans>
</CommandItem>
</BaseCommandGroup>
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
<CommandItem
keywords={[t`Profile`]}
value="navigation.settings.profile"
onSelect={() => onNavigate("/dashboard/settings/profile")}
>
<UserCircleIcon />
<Trans>Profile</Trans>
</CommandItem>
<CommandItem
keywords={[t`Preferences`]}
value="navigation.settings.preferences"
onSelect={() => onNavigate("/dashboard/settings/preferences")}
>
<GearIcon />
<Trans>Preferences</Trans>
</CommandItem>
<CommandItem
keywords={[t`Authentication`]}
value="navigation.settings.authentication"
onSelect={() => onNavigate("/dashboard/settings/authentication")}
>
<ShieldCheckIcon />
<Trans>Authentication</Trans>
</CommandItem>
<CommandItem
keywords={[t`API Keys`]}
value="navigation.settings.api-keys"
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
>
<KeyIcon />
<Trans>API Keys</Trans>
</CommandItem>
<CommandItem
keywords={[t`Integrations`, t`Artificial Intelligence`]}
value="navigation.settings.integrations"
onSelect={() => onNavigate("/dashboard/settings/integrations")}
>
<OpenAiLogoIcon />
<Trans>Integrations</Trans>
</CommandItem>
<CommandItem
keywords={[t`Danger Zone`]}
value="navigation.settings.danger-zone"
onSelect={() => onNavigate("/dashboard/settings/danger-zone")}
>
<WarningIcon />
<Trans>Danger Zone</Trans>
</CommandItem>
</BaseCommandGroup>
</>
);
}
@@ -0,0 +1,52 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/features/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { PreferencesCommandGroup } = await import("./index");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderGroup = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<PreferencesCommandGroup />
</Command>
</I18nProvider>,
);
describe("PreferencesCommandGroup", () => {
it("renders 'Change theme to...' and 'Change language to...' at the root", () => {
renderGroup();
expect(screen.getByText("Change theme to...")).toBeInTheDocument();
expect(screen.getByText("Change language to...")).toBeInTheDocument();
});
it("pushes 'theme' onto the page stack when the theme item is selected", () => {
renderGroup();
const item = screen.getByText("Change theme to...");
fireEvent.click(item);
expect(useCommandPaletteStore.getState().pages).toContain("theme");
});
it("pushes 'language' onto the page stack when the language item is selected", () => {
renderGroup();
fireEvent.click(screen.getByText("Change language to..."));
expect(useCommandPaletteStore.getState().pages).toContain("language");
});
});
@@ -0,0 +1,30 @@
import { Trans } from "@lingui/react/macro";
import { PaletteIcon, TranslateIcon } from "@phosphor-icons/react";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
import { BaseCommandGroup } from "../base";
import { LanguageCommandPage } from "./language";
import { ThemeCommandPage } from "./theme";
export function PreferencesCommandGroup() {
const pushPage = useCommandPaletteStore((state) => state.pushPage);
return (
<>
<BaseCommandGroup heading={<Trans>Preferences</Trans>}>
<CommandItem onSelect={() => pushPage("theme")}>
<PaletteIcon />
<Trans>Change theme to...</Trans>
</CommandItem>
<CommandItem onSelect={() => pushPage("language")}>
<TranslateIcon />
<Trans>Change language to...</Trans>
</CommandItem>
</BaseCommandGroup>
<ThemeCommandPage />
<LanguageCommandPage />
</>
);
}
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { localeMap } from "@/libs/locale";
import { useCommandPaletteStore } from "../../store";
import { LanguageCommandPage } from "./language";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<LanguageCommandPage />
</Command>
</I18nProvider>,
);
describe("LanguageCommandPage", () => {
it("does NOT render when the page stack does not have 'language' on top", () => {
renderPage();
// localeMap codes shouldn't appear because BaseCommandGroup gating is off.
expect(screen.queryByText("en-US")).toBeNull();
});
it("renders one CommandItem for every entry in localeMap when active", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
const expectedCount = Object.keys(localeMap).length;
expect(expectedCount).toBeGreaterThan(0);
// Each locale value is rendered in the inline font-mono span.
for (const code of Object.keys(localeMap).slice(0, 5)) {
expect(screen.getByText(code)).toBeInTheDocument();
}
});
it("includes the documented set of locales (sample check)", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
// Spot-check a couple of common locales.
for (const code of ["en-US", "de-DE", "ja-JP"]) {
if (code in localeMap) {
expect(screen.getByText(code)).toBeInTheDocument();
}
}
});
});
@@ -0,0 +1,27 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
export function LanguageCommandPage() {
const { i18n } = useLingui();
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{i18n.t(label)}
</CommandItem>
))}
</BaseCommandGroup>
);
}
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/features/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { ThemeCommandPage } = await import("./theme");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<ThemeCommandPage />
</Command>
</I18nProvider>,
);
describe("ThemeCommandPage", () => {
it("is hidden when 'theme' is not on top of the page stack", () => {
renderPage();
expect(screen.queryByText("Light theme")).toBeNull();
});
it("renders Light + Dark options when active", () => {
useCommandPaletteStore.setState({ pages: ["theme"] });
renderPage();
expect(screen.getByText("Light theme")).toBeInTheDocument();
expect(screen.getByText("Dark theme")).toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
import { Trans } from "@lingui/react/macro";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useTheme } from "@/features/theme/provider";
import { useCommandPaletteStore } from "../../store";
import { BaseCommandGroup } from "../base";
export function ThemeCommandPage() {
const { setTheme } = useTheme();
const setOpen = useCommandPaletteStore((state) => state.setOpen);
const handleThemeChange = (theme: "light" | "dark") => {
setTheme(theme, { playSound: false });
setOpen(false);
};
return (
<BaseCommandGroup page="theme" heading={<Trans>Theme</Trans>}>
<CommandItem value="light" onSelect={() => handleThemeChange("light")}>
<SunIcon />
<Trans>Light theme</Trans>
</CommandItem>
<CommandItem value="dark" onSelect={() => handleThemeChange("dark")}>
<MoonIcon />
<Trans>Dark theme</Trans>
</CommandItem>
</BaseCommandGroup>
);
}
@@ -0,0 +1,84 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandLoading } from "cmdk";
import { CommandItem, CommandShortcut } from "@reactive-resume/ui/components/command";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { useDialogStore } from "@/dialogs/store";
import { orpc } from "@/libs/orpc/client";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
export function ResumesCommandGroup() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { session } = useRouteContext({ strict: false });
const reset = useCommandPaletteStore((state) => state.reset);
const peekPage = useCommandPaletteStore((state) => state.peekPage);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
const isResumesPage = peekPage() === "resumes";
const { data: resumes, isLoading } = useQuery(
orpc.resume.list.queryOptions({
enabled: !!session && isResumesPage,
}),
);
const onCreate = async () => {
await navigate({ to: "/dashboard/resumes" });
openDialog("resume.create", undefined);
reset();
};
const onNavigate = async (path: string) => {
await navigate({ to: path });
reset();
};
if (!session) return null;
return (
<>
<BaseCommandGroup heading={<Trans>Search for...</Trans>}>
<CommandItem keywords={[t`Resumes`]} value="search.resumes" onSelect={() => pushPage("resumes")}>
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
</CommandItem>
</BaseCommandGroup>
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
<CommandItem onSelect={onCreate}>
<PlusIcon />
<Trans>Create a new resume</Trans>
</CommandItem>
{isLoading ? (
<CommandLoading>
<Trans>Loading resumes...</Trans>
</CommandLoading>
) : (
resumes?.map((resume) => (
<CommandItem
key={resume.id}
value={resume.id}
keywords={[resume.name]}
onSelect={() => onNavigate(`/builder/${resume.id}`)}
>
<ReadCvLogoIcon />
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
</CommandShortcut>
</CommandItem>
))
)}
</BaseCommandGroup>
</>
);
}
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from "vitest";
import { useCommandPaletteStore } from "./store";
const reset = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(reset);
describe("useCommandPaletteStore", () => {
it("starts closed with empty search and no pages", () => {
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setOpen(true) opens the palette without touching other fields", () => {
useCommandPaletteStore.setState({ search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(true);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(true);
expect(state.search).toBe("foo");
expect(state.pages).toEqual(["page1"]);
});
it("setOpen(false) resets the entire store back to initial state", () => {
useCommandPaletteStore.setState({ open: true, search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(false);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setSearch updates only the search field", () => {
useCommandPaletteStore.getState().setSearch("query");
expect(useCommandPaletteStore.getState().search).toBe("query");
});
it("pushPage appends a page and clears search", () => {
useCommandPaletteStore.setState({ search: "leftover" });
useCommandPaletteStore.getState().pushPage("settings");
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["settings"]);
expect(state.search).toBe("");
});
it("pushPage stacks multiple pages in order", () => {
const { pushPage } = useCommandPaletteStore.getState();
pushPage("a");
pushPage("b");
pushPage("c");
expect(useCommandPaletteStore.getState().pages).toEqual(["a", "b", "c"]);
});
it("peekPage returns the top page (or undefined when empty)", () => {
expect(useCommandPaletteStore.getState().peekPage()).toBeUndefined();
useCommandPaletteStore.setState({ pages: ["a", "b"] });
expect(useCommandPaletteStore.getState().peekPage()).toBe("b");
});
it("popPage removes the last page and clears search", () => {
useCommandPaletteStore.setState({ pages: ["a", "b"], search: "x" });
useCommandPaletteStore.getState().popPage();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.search).toBe("");
});
it("reset clears every state field", () => {
useCommandPaletteStore.setState({ open: true, search: "x", pages: ["a"] });
useCommandPaletteStore.getState().reset();
expect(useCommandPaletteStore.getState()).toMatchObject({ open: false, search: "", pages: [] });
});
describe("goBack", () => {
it("clears search first if present, leaving pages and open untouched", () => {
useCommandPaletteStore.setState({ open: true, search: "text", pages: ["a"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.search).toBe("");
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("pops the top page when no search and pages exist", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: ["a", "b"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("closes the palette when no search and no pages", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: [] });
useCommandPaletteStore.getState().goBack();
expect(useCommandPaletteStore.getState().open).toBe(false);
});
});
});
@@ -0,0 +1,59 @@
import { create } from "zustand/react";
type CommandPaletteState = {
open: boolean;
search: string;
pages: string[];
};
type CommandPaletteActions = {
setOpen: (open: boolean) => void;
setSearch: (search: string) => void;
pushPage: (page: string) => void;
peekPage: () => string | undefined;
popPage: () => void;
reset: () => void;
goBack: () => void;
};
type CommandPaletteStore = CommandPaletteState & CommandPaletteActions;
const initialState: CommandPaletteState = {
open: false,
search: "",
pages: [],
};
export const useCommandPaletteStore = create<CommandPaletteStore>((set, get) => ({
...initialState,
setOpen: (open) => {
set({ open });
if (!open) set(initialState);
},
setSearch: (search) => set({ search }),
peekPage: () => get().pages[get().pages.length - 1],
pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })),
popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })),
reset: () => set(initialState),
goBack: () => {
set((state) => {
if (state.search.length > 0) {
// If there's text, just clear the search
return { search: "" };
}
if (state.pages.length > 0) {
// If on a sub-page, go back
return { pages: state.pages.slice(0, -1), search: "" };
}
// If on first page, close
return { open: false, search: "", pages: [] };
});
},
}));
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getLocaleOptions", () => {
it("returns one option per entry in localeMap", () => {
const options = getLocaleOptions();
expect(options).toHaveLength(Object.keys(localeMap).length);
});
it("uses the locale code as the value", () => {
const options = getLocaleOptions();
const values = options.map((opt) => opt.value);
expect(values).toContain("en-US");
expect(values).toContain("de-DE");
});
it("populates label and keywords with the same translated string", () => {
const options = getLocaleOptions();
const enUS = options.find((opt) => opt.value === "en-US");
expect(enUS?.label).toBeTruthy();
expect(enUS?.keywords).toEqual([enUS?.label]);
});
it("uses unique values for every option", () => {
const values = getLocaleOptions().map((opt) => opt.value);
expect(new Set(values).size).toBe(values.length);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => ({
value: value as Locale,
label: i18n.t(label),
keywords: [i18n.t(label)],
}));
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<Combobox
showClear={false}
defaultValue={i18n.locale}
options={getLocaleOptions()}
onValueChange={onLocaleChange}
{...props}
/>
);
}
@@ -0,0 +1,404 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { WritableDraft } from "immer";
import { t } from "@lingui/core/macro";
import { consumeEventIterator } from "@orpc/client";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { debounce, isEqual } from "es-toolkit";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { applyResumePatches, createResumePatches } from "@reactive-resume/resume/patch";
import { orpc, streamClient } from "@/libs/orpc/client";
export type Resume = {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isLocked: boolean;
updatedAt: Date;
hasPassword?: boolean;
isPublic?: boolean;
};
type ResumeStoreState = {
resume: Resume | null;
resumeId?: string;
isReady: boolean;
};
type ResumeStoreActions = {
initialize: (resume: Resume | null) => void;
reset: () => void;
replaceResumeDraft: (resume: Resume) => void;
replaceResumeFromServer: (resume: Resume) => void;
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
mergeResumeMetadata: (resume: Resume) => void;
};
type ResumeStore = ResumeStoreState & ResumeStoreActions;
type Runtime = {
abortController: AbortController;
queryClient?: QueryClient;
baselineData?: ResumeData;
hasPendingLocalChanges: boolean;
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
beforeUnloadHandler?: () => void;
};
const SAVE_DEBOUNCE_MS = 500;
const runtimes = new Map<string, Runtime>();
let lockedToastId: string | number | undefined;
function getResumeQueryKey(id: string): QueryKey {
return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey;
}
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = false;
}
function createRuntime(): Runtime {
const abortController = new AbortController();
const syncResume = debounce(
async (resume: Resume) => {
const runtime = runtimes.get(resume.id);
if (!runtime) return;
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
const operations = createResumePatches(baselineData, resume.data);
if (operations.length === 0) {
runtime.hasPendingLocalChanges = false;
return;
}
const submittedData = cloneResumeData(resume.data);
try {
const updated = (await orpc.resume.patch.call(
{ id: resume.id, operations },
{ signal: abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
runtime.baselineData = cloneResumeData(updated.data);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
syncCurrentResume(resume.id);
}
if (runtime.syncErrorToastId === undefined) return;
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
});
}
},
SAVE_DEBOUNCE_MS,
{ signal: abortController.signal },
);
const runtime: Runtime = {
abortController,
hasPendingLocalChanges: false,
syncResume,
};
if (typeof window !== "undefined") {
runtime.beforeUnloadHandler = () => runtime.syncResume.flush();
window.addEventListener("beforeunload", runtime.beforeUnloadHandler);
}
return runtime;
}
function getRuntime(id: string): Runtime {
const existing = runtimes.get(id);
if (existing) return existing;
const runtime = createRuntime();
runtimes.set(id, runtime);
return runtime;
}
function bindRuntimeQueryClient(id: string, queryClient: QueryClient) {
getRuntime(id).queryClient = queryClient;
}
function hasPendingLocalChanges(id: string): boolean {
return getRuntime(id).hasPendingLocalChanges;
}
function cleanupRuntime(id: string) {
const runtime = runtimes.get(id);
if (!runtime) return;
runtime.syncResume.flush();
runtime.abortController.abort();
if (runtime.beforeUnloadHandler && typeof window !== "undefined") {
window.removeEventListener("beforeunload", runtime.beforeUnloadHandler);
}
runtimes.delete(id);
}
function syncCurrentResume(id: string) {
const resume = useResumeStore.getState().resume;
if (!resume || resume.id !== id) return;
getRuntime(id).syncResume(resume);
}
export const useResumeStore = create<ResumeStore>()(
immer((set, get) => ({
resume: null,
resumeId: undefined,
isReady: false,
initialize: (resume) => {
if (resume) setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume?.id;
state.isReady = resume !== null;
});
},
reset: () => {
set((state) => {
state.resume = null;
state.resumeId = undefined;
state.isReady = false;
});
},
replaceResumeDraft: (resume) => {
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
replaceResumeFromServer: (resume) => {
setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
patchResume: (fn) => {
set((state) => {
if (!state.resume) return;
fn(state.resume as WritableDraft<Resume>);
});
},
mergeResumeMetadata: (resume) => {
set((state) => {
if (!state.resume || state.resume.id !== resume.id) return;
state.resume.name = resume.name;
state.resume.slug = resume.slug;
state.resume.tags = resume.tags;
state.resume.isLocked = resume.isLocked;
state.resume.updatedAt = resume.updatedAt;
state.resume.hasPassword = resume.hasPassword;
state.resume.isPublic = resume.isPublic;
});
},
updateResumeData: (fn) => {
const currentResume = get().resume;
if (!currentResume) return;
if (currentResume.isLocked) {
lockedToastId = toast.error(t`This resume is locked and cannot be updated.`, {
id: lockedToastId,
});
return;
}
set((state) => {
if (!state.resume) return;
fn(state.resume.data as WritableDraft<ResumeData>);
});
getRuntime(currentResume.id).hasPendingLocalChanges = true;
syncCurrentResume(currentResume.id);
},
})),
);
export function useInitializeResumeStore() {
return useResumeStore((state) => state.initialize);
}
function useResetResumeStore() {
return useResumeStore((state) => state.reset);
}
export function useMergeResumeMetadata() {
return useResumeStore((state) => state.mergeResumeMetadata);
}
export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
return useResumeStore((state) => {
if (!resumeId || !state.resume || state.resume.id !== resumeId) return undefined;
return selector(state.resume);
});
}
export function useCurrentBuilderResumeSelector<T>(selector: (resume: Resume) => T): T {
const selected = useBuilderResumeSelector(selector);
if (selected === undefined) throw new Error("Resume data is required before rendering this component.");
return selected;
}
export function useResume(): Resume | undefined {
return useBuilderResumeSelector((resume) => resume);
}
export function useCurrentResume(): Resume {
const resume = useResume();
if (!resume) throw new Error("Resume data is required before rendering this component.");
return resume;
}
export function useResumeData(): ResumeData | undefined {
return useBuilderResumeSelector((resume) => resume.data);
}
export function useUpdateResumeData() {
const queryClient = useQueryClient();
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const updateResumeData = useResumeStore((state) => state.updateResumeData);
return useCallback(
(fn: (draft: WritableDraft<ResumeData>) => void) => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
updateResumeData(fn);
},
[queryClient, resumeId, updateResumeData],
);
}
export function useResumeUpdateSubscription() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const [_retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
onEvent: async () => {
try {
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
if (hasPendingLocalChanges(resumeId)) {
const runtime = getRuntime(resumeId);
const currentResume = useResumeStore.getState().resume;
const baselineData = runtime.baselineData ?? currentResume?.data;
if (currentResume && baselineData) {
const localOperations = createResumePatches(baselineData, currentResume.data);
const mergedData = applyResumePatches(resume.data, localOperations);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = localOperations.length > 0;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
syncCurrentResume(resumeId);
} else {
runtime.baselineData = cloneResumeData(resume.data);
useResumeStore.getState().mergeResumeMetadata(resume);
}
return;
}
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
replaceResumeFromServer(resume);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.warn("Failed to refresh resume after update event:", error);
}
},
onError: (error) => {
if (didCancel) return;
console.warn("Resume update stream failed, reconnecting:", error);
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
},
});
return () => {
didCancel = true;
if (retryTimer) window.clearTimeout(retryTimer);
void cancel().catch(() => {});
};
}, [queryClient, replaceResumeFromServer, resumeId]);
}
export function useResumeCleanup() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const reset = useResetResumeStore();
useEffect(() => {
if (!resumeId) return;
return () => {
cleanupRuntime(resumeId);
reset();
};
}, [resumeId, reset]);
}
@@ -0,0 +1,32 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useMemo } from "react";
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
import { ResumeDocument } from "@reactive-resume/pdf/document";
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "@/libs/resume/section-title-locale";
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
return useMemo(() => {
if (!data || !sectionTitleResolver) return null;
return (
<ResumeDocument
data={data}
template={template ?? data.metadata.template}
resolveSectionTitle={sectionTitleResolver}
/>
);
}, [data, template, sectionTitleResolver]);
};
export const createResumePdfBlob = async (data: ResumeData, template?: Template) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
return createPdfBlob({
data,
template,
resolveSectionTitle: sectionTitleResolver,
});
};
@@ -0,0 +1,203 @@
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist/legacy/build/pdf.mjs";
import type { ReactNode } from "react";
import type { PreviewPageSize } from "./preview.shared";
import {
AnnotationMode,
GlobalWorkerOptions,
getDocument,
RenderingCancelledException,
} from "pdfjs-dist/legacy/build/pdf.mjs";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
type PdfCanvasDocumentProps = {
children: (document: PDFDocumentProxy) => ReactNode;
file: Blob;
onLoadSuccess: (document: PDFDocumentProxy) => void;
};
type PdfCanvasPageProps = {
className?: string;
document: PDFDocumentProxy;
onLoadSuccess: (pageNumber: number, pageSize: PreviewPageSize) => void;
onRenderSuccess?: () => void;
pageNumber: number;
pageScale: number;
pageSize?: PreviewPageSize;
showPageNumbers: boolean;
totalPages: number;
};
const isRenderingCancelledError = (error: unknown) =>
error instanceof RenderingCancelledException ||
(typeof error === "object" && error !== null && "name" in error && error.name === "RenderingCancelledException");
export function PdfCanvasDocument({ children, file, onLoadSuccess }: PdfCanvasDocumentProps) {
const [document, setDocument] = useState<PDFDocumentProxy | null>(null);
const onLoadSuccessRef = useRef(onLoadSuccess);
useEffect(() => {
onLoadSuccessRef.current = onLoadSuccess;
}, [onLoadSuccess]);
useEffect(() => {
let isCancelled = false;
let loadingTask: PDFDocumentLoadingTask | undefined;
let loadedDocument: PDFDocumentProxy | undefined;
const loadDocument = async () => {
setDocument(null);
const arrayBuffer = await file.arrayBuffer();
if (isCancelled) return;
loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
const pdfDocument = await loadingTask.promise;
if (isCancelled) {
void pdfDocument.destroy();
return;
}
loadedDocument = pdfDocument;
setDocument(pdfDocument);
onLoadSuccessRef.current(pdfDocument);
};
void loadDocument().catch((error: unknown) => {
if (isCancelled) return;
console.error("Failed to load PDF document", error);
});
return () => {
isCancelled = true;
if (loadedDocument) {
void loadedDocument.destroy();
} else if (loadingTask) {
void loadingTask.destroy();
}
};
}, [file]);
if (!document) return null;
return children(document);
}
export function PdfCanvasPage({
className,
document,
onLoadSuccess,
onRenderSuccess,
pageNumber,
pageScale,
pageSize = DEFAULT_PDF_PAGE_SIZE,
showPageNumbers,
totalPages,
}: PdfCanvasPageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const onLoadSuccessRef = useRef(onLoadSuccess);
const onRenderSuccessRef = useRef(onRenderSuccess);
const scaledPageSize = getScaledPreviewPageSize(pageSize, pageScale);
useEffect(() => {
onLoadSuccessRef.current = onLoadSuccess;
onRenderSuccessRef.current = onRenderSuccess;
}, [onLoadSuccess, onRenderSuccess]);
useEffect(() => {
let isCancelled = false;
let renderTask: RenderTask | undefined;
const renderPage = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const page = await document.getPage(pageNumber);
try {
if (isCancelled) {
page.cleanup();
return;
}
const baseViewport = page.getViewport({ scale: 1 });
const pageSize = { height: baseViewport.height, width: baseViewport.width };
onLoadSuccessRef.current(pageNumber, pageSize);
const width = baseViewport.width * pageScale;
const height = baseViewport.height * pageScale;
const renderScale = getPreviewCanvasScale(width, height);
const canvasContext = canvas.getContext("2d");
if (!canvasContext) return;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = Math.floor(width * renderScale);
canvas.height = Math.floor(height * renderScale);
canvasContext.setTransform(1, 0, 0, 1, 0, 0);
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
const viewport = page.getViewport({ scale: pageScale });
const transform = [renderScale, 0, 0, renderScale, 0, 0];
renderTask = page.render({
canvas,
canvasContext,
viewport,
transform,
annotationMode: AnnotationMode.DISABLE,
background: "white",
});
await renderTask.promise;
renderTask = undefined;
if (!isCancelled) onRenderSuccessRef.current?.();
} finally {
page.cleanup();
}
};
void renderPage().catch((error: unknown) => {
if (isRenderingCancelledError(error)) return;
console.error(`Failed to render PDF page ${pageNumber}`, error);
});
return () => {
isCancelled = true;
if (renderTask) {
renderTask.cancel();
}
};
}, [document, pageNumber, pageScale]);
return (
<figure className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {totalPages}
</figcaption>
) : null}
<div
role="img"
aria-label={`Resume page ${pageNumber} of ${totalPages}`}
style={scaledPageSize}
className={cn("aspect-page overflow-hidden rounded-md", className)}
>
<canvas ref={canvasRef} />
</div>
</figure>
);
}
@@ -0,0 +1,69 @@
import type { PreviewPageSize } from "./preview.shared";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
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");
});
};
export const createPdfFirstPageImageUrl = async (file: Blob) => {
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/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 pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width };
const renderSize = getResumeThumbnailRenderSize(
pageSize,
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();
}
}
};
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const pdfjsMock = vi.hoisted(() => {
const page = {
cleanup: vi.fn(),
getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })),
render: vi.fn(() => ({ promise: Promise.resolve() })),
};
const pdfDocument = {
destroy: vi.fn(),
getPage: vi.fn(async () => page),
numPages: 1,
};
const loadingTask = {
destroy: vi.fn(),
promise: Promise.resolve(pdfDocument),
};
return {
createObjectURL: vi.fn(() => "blob:thumbnail"),
getDocument: vi.fn(() => loadingTask),
legacyModule: {
AnnotationMode: { DISABLE: 0 },
getDocument: vi.fn(() => loadingTask),
GlobalWorkerOptions: {} as { workerSrc?: string },
RenderingCancelledException: class RenderingCancelledException extends Error {},
},
loadingTask,
page,
pdfDocument,
};
});
vi.mock("pdfjs-dist", () => {
throw new Error("Modern pdfjs-dist runtime should not be imported by browser preview code.");
});
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => pdfjsMock.legacyModule);
describe("PDF.js browser entrypoints", () => {
beforeEach(() => {
pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc = undefined;
pdfjsMock.legacyModule.getDocument.mockClear();
pdfjsMock.loadingTask.destroy.mockClear();
pdfjsMock.pdfDocument.destroy.mockClear();
pdfjsMock.pdfDocument.getPage.mockClear();
pdfjsMock.page.cleanup.mockClear();
pdfjsMock.page.getViewport.mockClear();
pdfjsMock.page.render.mockClear();
pdfjsMock.createObjectURL.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loads the canvas preview renderer from the legacy PDF.js runtime", async () => {
await expect(import("./pdf-canvas")).resolves.toEqual(
expect.objectContaining({
PdfCanvasDocument: expect.any(Function),
PdfCanvasPage: expect.any(Function),
}),
);
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
);
});
it("creates thumbnails with the legacy PDF.js runtime", async () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback) => {
callback(new Blob(["png"], { type: "image/png" }));
});
vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL);
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe(
"blob:thumbnail",
);
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
);
expect(pdfjsMock.legacyModule.getDocument).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.any(Uint8Array) }),
);
expect(pdfjsMock.page.render).toHaveBeenCalledWith(
expect.objectContaining({ annotationMode: 0, background: "white" }),
);
expect(pdfjsMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumePreviewClient } from "./preview.browser";
const previewMock = vi.hoisted(() => ({
builderResumeData: undefined as ResumeData | undefined,
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
}));
const resumeDataWithPageCount = (pageCount: number): ResumeData => ({
...sampleResumeData,
metadata: {
...sampleResumeData.metadata,
layout: {
...sampleResumeData.metadata.layout,
pages: sampleResumeData.metadata.layout.pages.slice(0, pageCount),
},
},
});
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: previewMock.toBlob,
}));
vi.mock("../builder/draft", () => ({
useResumeData: () => previewMock.builderResumeData,
}));
vi.mock("./pdf-canvas", async () => {
const React = await import("react");
const pdfDocument = { numPages: 1 };
return {
PdfCanvasDocument: ({
children,
onLoadSuccess,
}: {
children: (document: typeof pdfDocument) => React.ReactNode;
onLoadSuccess: (document: typeof pdfDocument) => void;
}) => {
React.useEffect(() => {
onLoadSuccess(pdfDocument);
}, [onLoadSuccess]);
return React.createElement(React.Fragment, null, children(pdfDocument));
},
PdfCanvasPage: ({
onLoadSuccess,
onRenderSuccess,
pageNumber,
totalPages,
}: {
onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void;
onRenderSuccess?: () => void;
pageNumber: number;
totalPages: number;
}) => {
React.useEffect(() => {
onLoadSuccess(pageNumber, { height: 200, width: 100 });
onRenderSuccess?.();
}, [onLoadSuccess, onRenderSuccess, pageNumber]);
return React.createElement(
"div",
{ role: "img", "aria-label": `Resume page ${pageNumber} of ${totalPages}` },
"Rendered page",
);
},
};
});
describe("ResumePreviewClient", () => {
beforeEach(() => {
previewMock.builderResumeData = undefined;
previewMock.toBlob.mockReset();
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
});
it("renders a loading placeholder for each builder layout page while the PDF is generated", () => {
previewMock.builderResumeData = resumeDataWithPageCount(3);
previewMock.toBlob.mockImplementation(() => new Promise<Blob>(() => {}));
render(<ResumePreviewClient pageGap={16} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
});
it("renders from explicit resume data when no builder resume is active", async () => {
render(
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
await waitFor(() => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
});
});
@@ -0,0 +1,217 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
type PreviewPdf = {
file: Blob;
id: number;
numPages: number;
pageSizes: Record<number, PreviewPageSize>;
phase: "active" | "exiting" | "staged";
renderedPages: number[];
};
const UPDATE_DEBOUNCE_MS = 100;
const CROSSFADE_DURATION_MS = 180;
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean): PreviewPdf => ({
file,
id,
numPages: 0,
pageSizes: {},
phase: hasExistingPreview ? "staged" : "active",
renderedPages: [],
});
const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => {
const activeLayers = layers.filter((layer) => layer.phase === "active");
return activeLayers.length === 0 ? [nextPdf] : [...activeLayers, nextPdf];
};
const getActivePreviewLayer = (layers: PreviewPdf[]) => layers.find((layer) => layer.phase === "active") ?? null;
const setPreviewPageCount = (layers: PreviewPdf[], layerId: number, numPages: number) =>
layers.map((layer) => (layer.id === layerId ? { ...layer, numPages } : layer));
const setPreviewPageSize = (layers: PreviewPdf[], layerId: number, pageNumber: number, pageSize: PreviewPageSize) =>
layers.map((layer) =>
layer.id === layerId
? {
...layer,
pageSizes: {
...layer.pageSizes,
[pageNumber]: pageSize,
},
}
: layer,
);
const markPreviewPageRendered = (layers: PreviewPdf[], layerId: number, pageNumber: number) => {
let shouldPromoteLayer = false;
const nextLayers = layers.map((layer) => {
if (layer.id !== layerId || layer.renderedPages.includes(pageNumber)) return layer;
const renderedPages = [...layer.renderedPages, pageNumber];
const nextLayer = { ...layer, renderedPages };
if (layer.phase === "staged" && renderedPages.length >= layer.numPages) {
shouldPromoteLayer = true;
return { ...nextLayer, phase: "active" as const };
}
return nextLayer;
});
if (!shouldPromoteLayer) return nextLayers;
return nextLayers.map((layer) => {
if (layer.id === layerId) return layer;
if (layer.phase === "active") return { ...layer, phase: "exiting" as const };
return layer;
});
};
const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.filter((layer) => layer.id !== layerId);
export function ResumePreviewClient({
className,
data,
pageGap = 16,
pageLayout,
pageScale,
pageClassName,
showPageNumbers,
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
const pdfIdRef = useRef(0);
const requestIdRef = useRef(0);
const hasPreviewRef = useRef(false);
useEffect(() => {
if (!resumeData) return;
let cancelled = false;
const requestId = ++requestIdRef.current;
const delay = hasPreviewRef.current ? UPDATE_DEBOUNCE_MS : 0;
const generatePdfPreview = async () => {
try {
const blob = await createResumePdfBlob(resumeData);
if (cancelled || requestId !== requestIdRef.current) return;
const nextPdf = createPreviewPdf(blob, pdfIdRef.current++, hasPreviewRef.current);
hasPreviewRef.current = true;
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
} catch {}
};
const timeoutId = window.setTimeout(() => {
void generatePdfPreview();
}, delay);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [resumeData]);
if (!resumeData) return null;
const visiblePdf = getActivePreviewLayer(previewLayers);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
if (!visiblePdf) {
return (
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
);
}
return (
<div className={cn("grid", className)}>
<AnimatePresence initial={false}>
{previewLayers.map((visiblePdf) => (
<motion.div
key={visiblePdf.id}
aria-hidden={visiblePdf.phase !== "active"}
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={{ duration: CROSSFADE_DURATION_MS / 1000, ease: "easeOut" }}
onAnimationComplete={() => {
if (visiblePdf.phase !== "exiting") return;
setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id));
}}
>
<PdfCanvasDocument
file={visiblePdf.file}
onLoadSuccess={(document) => {
setPreviewLayers((current) => setPreviewPageCount(current, visiblePdf.id, document.numPages));
}}
>
{(document) => (
<div
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
>
{Array.from({ length: visiblePdf.numPages }, (_, index) => {
const pageNumber = index + 1;
const totalPages = visiblePdf.numPages;
const pageSize = visiblePdf.pageSizes[pageNumber];
return (
<PdfCanvasPage
key={`${visiblePdf.id}-${pageNumber}`}
document={document}
pageSize={pageSize}
pageNumber={pageNumber}
pageScale={pageScale}
totalPages={totalPages}
className={pageClassName}
showPageNumbers={showPageNumbers}
onLoadSuccess={(_, pageSize) => {
setPreviewLayers((current) =>
setPreviewPageSize(current, visiblePdf.id, pageNumber, pageSize),
);
}}
onRenderSuccess={() => {
if (visiblePdf.phase !== "staged") return;
setPreviewLayers((current) => markPreviewPageRendered(current, visiblePdf.id, pageNumber));
}}
/>
);
})}
</div>
)}
</PdfCanvasDocument>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_PDF_PAGE_SIZE,
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
normalizeResumePreviewProps,
} from "./preview.shared";
describe("normalizeResumePreviewProps", () => {
it("applies the documented defaults when fields are omitted", () => {
const result = normalizeResumePreviewProps({});
expect(result).toMatchObject({
pageGap: 16,
pageLayout: "horizontal",
pageScale: 1,
showPageNumbers: false,
});
});
it("preserves supplied values and forwards extra props (className, data)", () => {
const result = normalizeResumePreviewProps({
className: "preview-class",
pageGap: 16,
pageLayout: "vertical",
pageScale: 1.5,
showPageNumbers: true,
});
expect(result.className).toBe("preview-class");
expect(result.pageGap).toBe(16);
expect(result.pageLayout).toBe("vertical");
expect(result.pageScale).toBe(1.5);
expect(result.showPageNumbers).toBe(true);
});
});
describe("getScaledPreviewPageSize", () => {
it("multiplies both dimensions by the scale", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 2);
expect(result).toEqual({ width: 200, height: 400 });
});
it("returns the default A4 page size unchanged when scaled by 1", () => {
expect(getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, 1)).toEqual(DEFAULT_PDF_PAGE_SIZE);
});
it("supports fractional scaling", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 0.5);
expect(result).toEqual({ width: 50, height: 100 });
});
});
describe("getResumePreviewGapValue", () => {
it("adds px units for numeric custom-property gap values", () => {
expect(getResumePreviewGapValue(96)).toBe("96px");
});
it("preserves explicit zero gap", () => {
expect(getResumePreviewGapValue(0)).toBe(0);
});
it("preserves string gap values", () => {
expect(getResumePreviewGapValue("1rem")).toBe("1rem");
});
});
const setDevicePixelRatio = (value: number) => {
Object.defineProperty(window, "devicePixelRatio", {
writable: true,
configurable: true,
value,
});
};
afterEach(() => {
setDevicePixelRatio(1);
});
describe("getPreviewCanvasScale", () => {
it("returns the desired render scale (4x) for small pages", () => {
setDevicePixelRatio(1);
// width * height * 4 * 4 = 100 * 100 * 16 = 160_000 ≪ 16_777_216 budget
expect(getPreviewCanvasScale(100, 100)).toBe(4);
});
it("uses devicePixelRatio when it exceeds the desired 4x scale", () => {
setDevicePixelRatio(8);
// 50*50*8*8 = 160_000 ≪ budget, so we keep the 8x devicePixelRatio
expect(getPreviewCanvasScale(50, 50)).toBe(8);
});
it("clamps the scale when the page would exceed the canvas pixel budget", () => {
setDevicePixelRatio(1);
const scale = getPreviewCanvasScale(2000, 3000);
// Should NOT exceed the 4x desired scale and must satisfy the pixel budget.
expect(scale).toBeLessThan(4);
expect(scale * scale * 2000 * 3000).toBeLessThanOrEqual(16_777_216 + 1);
});
});
@@ -0,0 +1,50 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
DEFAULT_PDF_PAGE_SIZE,
getResumePreviewPageCount,
getScaledPreviewPageSize,
ResumePreviewLoader,
} from "./preview.shared";
describe("ResumePreviewLoader", () => {
it("uses the same scaled page dimensions as the preview page", () => {
const pageScale = 1.25;
const expectedSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const { container } = render(<ResumePreviewLoader pageScale={pageScale} />);
const page = container.querySelector("figure > div") as HTMLElement | null;
expect(Number.parseFloat(page?.style.height ?? "0")).toBeCloseTo(expectedSize.height);
expect(Number.parseFloat(page?.style.width ?? "0")).toBeCloseTo(expectedSize.width);
});
it("renders one loading placeholder for each resume layout page", () => {
const pageCount = sampleResumeData.metadata.layout.pages.length;
render(<ResumePreviewLoader pageCount={pageCount} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount);
});
it("writes numeric page gaps as valid CSS custom-property lengths", () => {
const { container } = render(<ResumePreviewLoader pageGap={96} />);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue("--resume-preview-page-gap")).toBe(
"96px",
);
});
});
describe("getResumePreviewPageCount", () => {
it("uses the resume layout page count when resume data is available", () => {
expect(getResumePreviewPageCount(sampleResumeData)).toBe(sampleResumeData.metadata.layout.pages.length);
});
it("falls back to one page without resume data", () => {
expect(getResumePreviewPageCount()).toBe(1);
});
});
@@ -0,0 +1,118 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
export type ResumePreviewProps = {
className?: string;
data?: ResumeData;
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
pageClassName?: string;
showPageNumbers?: boolean;
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
pageLayout: "horizontal" | "vertical";
pageScale: number;
showPageNumbers: boolean;
};
export type PreviewPageSize = {
height: number;
width: number;
};
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
export const normalizeResumePreviewProps = ({
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
...props
}: ResumePreviewProps): ResolvedResumePreviewProps => ({
...props,
pageGap,
pageLayout,
pageScale,
showPageNumbers,
});
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
}: ResumePreviewLoaderProps) {
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
return (
<div
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
>
{Array.from({ length: pageCount }, (_, index) => {
const pageNumber = index + 1;
return (
<figure key={pageNumber} className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {pageCount}
</figcaption>
) : null}
<div
role="img"
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
style={pageSize}
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
>
<Spinner className="size-10" />
</div>
</figure>
);
})}
</div>
);
}
@@ -0,0 +1,38 @@
import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { useResumeData } from "../builder/draft";
import { getResumePreviewPageCount, normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared";
const ResumePreviewClient = lazy(() =>
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
);
export type { ResumePreviewProps };
export function ResumePreview(props: ResumePreviewProps) {
const isClient = useIsClient();
const resolvedProps = normalizeResumePreviewProps(props);
const builderResumeData = useResumeData();
const resumeData = resolvedProps.data ?? builderResumeData;
const pageCount = getResumePreviewPageCount(resumeData);
if (!isClient) return null;
return (
<Suspense
fallback={
<ResumePreviewLoader
pageCount={pageCount}
pageClassName={resolvedProps.pageClassName}
pageGap={resolvedProps.pageGap}
pageLayout={resolvedProps.pageLayout}
pageScale={resolvedProps.pageScale}
showPageNumbers={resolvedProps.showPageNumbers}
/>
}
>
<ResumePreviewClient {...resolvedProps} />
</Suspense>
);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
getResumeThumbnailCacheKey,
getResumeThumbnailRenderSize,
RESUME_THUMBNAIL_TARGET_WIDTH,
} from "./resume-thumbnail.shared";
describe("getResumeThumbnailCacheKey", () => {
it("composes id and updated-at epoch milliseconds with a colon", () => {
const date = new Date("2024-01-15T00:00:00.000Z");
expect(getResumeThumbnailCacheKey("abc", date)).toBe(`abc:${date.getTime()}`);
});
it("differs when updatedAt changes", () => {
const id = "resume-1";
const a = getResumeThumbnailCacheKey(id, new Date(1000));
const b = getResumeThumbnailCacheKey(id, new Date(2000));
expect(a).not.toBe(b);
});
});
describe("getResumeThumbnailRenderSize", () => {
it("uses the default target width when not provided", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 });
expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH);
// Scale = 420/800 = 0.525 → height rounds to 630
expect(size.height).toBe(630);
expect(size.scale).toBeCloseTo(0.525, 5);
});
it("scales relative to the provided target width", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400);
expect(size.width).toBe(400);
expect(size.scale).toBeCloseTo(0.5, 5);
expect(size.height).toBe(600);
});
it("clamps pixelRatio to a minimum of 1", () => {
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5);
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1);
expect(a).toEqual(b);
});
it("clamps pixelRatio to a maximum of 2", () => {
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3);
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
expect(a).toEqual(b);
});
it("multiplies width/height by pixelRatio when within bounds", () => {
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
// pageScale = 0.5, outputScale = 2 → width = 800, height = 1200
expect(size.width).toBe(800);
expect(size.height).toBe(1200);
expect(size.scale).toBeCloseTo(1, 5);
});
it("rounds the output dimensions to integers", () => {
const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5);
expect(Number.isInteger(size.width)).toBe(true);
expect(Number.isInteger(size.height)).toBe(true);
});
});
@@ -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,11 @@
.pdf-viewer .pdfViewer {
padding-block: 0;
}
.pdf-viewer .pdfViewer .page {
margin-block: 0 1rem;
}
.pdf-viewer .pdfViewer .page:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,119 @@
// @vitest-environment happy-dom
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const pdfViewerMock = vi.hoisted(() => {
const pdfDocument = {
destroy: vi.fn(),
numPages: 1,
};
const loadingTask = {
destroy: vi.fn(),
promise: Promise.resolve(pdfDocument),
};
return {
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
getDocument: vi.fn(() => loadingTask),
instances: [] as Array<{
abortSignal?: AbortSignal;
setDocument: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
}>,
loadingTask,
pdfDocument,
};
});
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: pdfViewerMock.createResumePdfBlob,
}));
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
AnnotationMode: { ENABLE_FORMS: 2 },
getDocument: pdfViewerMock.getDocument,
GlobalWorkerOptions: {},
}));
vi.mock("pdfjs-dist/legacy/web/pdf_viewer.mjs", () => {
class EventBus {
private readonly listeners = new Map<string, Set<() => void>>();
on(name: string, listener: () => void) {
const listeners = this.listeners.get(name) ?? new Set<() => void>();
listeners.add(listener);
this.listeners.set(name, listeners);
}
off(name: string, listener: () => void) {
this.listeners.get(name)?.delete(listener);
}
dispatch(name: string) {
for (const listener of this.listeners.get(name) ?? []) listener();
}
}
class PDFLinkService {
setDocument = vi.fn();
setViewer = vi.fn();
}
class PDFViewer {
currentScaleValue = "";
setDocument: ReturnType<typeof vi.fn>;
update = vi.fn();
constructor(options: { abortSignal?: AbortSignal; container: HTMLDivElement; eventBus: EventBus }) {
pdfViewerMock.constructorOptions.push(options);
this.setDocument = vi.fn((document: unknown) => {
if (document) options.eventBus.dispatch("pagesinit");
});
pdfViewerMock.instances.push(this);
}
}
return {
EventBus,
LinkTarget: { BLANK: 2 },
PDFLinkService,
PDFViewer,
};
});
const { PdfViewer } = await import("./pdf-viewer");
beforeEach(() => {
pdfViewerMock.constructorOptions.length = 0;
pdfViewerMock.instances.length = 0;
pdfViewerMock.createResumePdfBlob.mockClear();
pdfViewerMock.getDocument.mockClear();
pdfViewerMock.loadingTask.destroy.mockClear();
pdfViewerMock.pdfDocument.destroy.mockClear();
});
describe("PdfViewer", () => {
it("uses PDF.js' absolute container contract and disposes viewer state", async () => {
const view = render(<PdfViewer data={sampleResumeData} />);
await waitFor(() => expect(pdfViewerMock.constructorOptions).toHaveLength(1));
const [{ abortSignal, container }] = pdfViewerMock.constructorOptions;
const [viewer] = pdfViewerMock.instances;
expect(container).toHaveClass("absolute", "inset-0", "overflow-visible");
expect(abortSignal).toBeInstanceOf(AbortSignal);
expect(viewer.setDocument).toHaveBeenCalledWith(pdfViewerMock.pdfDocument);
view.unmount();
expect(abortSignal?.aborted).toBe(true);
expect(viewer.setDocument).toHaveBeenCalledWith(null);
expect(pdfViewerMock.pdfDocument.destroy).toHaveBeenCalledTimes(1);
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,190 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import "pdfjs-dist/legacy/web/pdf_viewer.css";
import "./pdf-viewer.css";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
type PdfViewerProps = {
className?: string;
data: ResumeData;
};
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
abortSignal: AbortSignal;
};
const clearPdfViewerDocument = (pdfViewer: PDFViewer) => {
(pdfViewer.setDocument as (document: PDFDocumentProxy | null) => void)(null);
};
export function PdfViewer({ className, data }: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const [file, setFile] = useState<Blob | null>(null);
const [error, setError] = useState(false);
const [isReady, setIsReady] = useState(false);
const [viewerHeight, setViewerHeight] = useState<number | null>(null);
useEffect(() => {
let isCancelled = false;
setError(false);
setFile(null);
setIsReady(false);
setViewerHeight(null);
void createResumePdfBlob(data)
.then((blob) => {
if (!isCancelled) setFile(blob);
})
.catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to generate public resume PDF", error);
setError(true);
}
});
return () => {
isCancelled = true;
};
}, [data]);
useEffect(() => {
const root = rootRef.current;
const container = containerRef.current;
const viewer = viewerRef.current;
if (!file || !root || !container || !viewer) return;
let isCancelled = false;
let animationFrameId = 0;
let resizeObserver: ResizeObserver | undefined;
const abortController = new AbortController();
let loadingTask: PDFDocumentLoadingTask | undefined;
let pdfDocument: PDFDocumentProxy | undefined;
let pdfViewer: PDFViewer | undefined;
const eventBus = new EventBus();
const linkService = new PDFLinkService({
eventBus,
externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: "noreferrer",
});
const syncViewerHeight = () => {
if (isCancelled) return;
window.cancelAnimationFrame(animationFrameId);
animationFrameId = window.requestAnimationFrame(() => {
if (isCancelled) return;
const nextHeight = Math.ceil(viewer.scrollHeight);
if (nextHeight > 0) setViewerHeight(nextHeight);
pdfViewer?.update();
});
};
const setInitialScale = () => {
if (!isCancelled && pdfViewer) {
pdfViewer.currentScaleValue = "page-width";
syncViewerHeight();
}
};
eventBus.on("pagesinit", setInitialScale);
eventBus.on("pagesloaded", syncViewerHeight);
eventBus.on("pagerendered", syncViewerHeight);
viewer.replaceChildren();
setError(false);
setIsReady(false);
setViewerHeight(null);
resizeObserver = new ResizeObserver(syncViewerHeight);
resizeObserver.observe(viewer);
const loadDocument = async () => {
const arrayBuffer = await file.arrayBuffer();
if (isCancelled) return;
loadingTask = getDocument({
data: new Uint8Array(arrayBuffer),
docBaseUrl: window.location.href,
});
const nextDocument = await loadingTask.promise;
if (isCancelled) {
void nextDocument.destroy();
return;
}
pdfDocument = nextDocument;
const pdfViewerOptions = {
annotationMode: AnnotationMode.ENABLE_FORMS,
container,
eventBus,
linkService,
removePageBorders: true,
abortSignal: abortController.signal,
viewer,
} satisfies PdfViewerOptions;
pdfViewer = new PDFViewer(pdfViewerOptions);
linkService.setViewer(pdfViewer);
pdfViewer.setDocument(pdfDocument);
linkService.setDocument(pdfDocument);
syncViewerHeight();
setIsReady(true);
};
void loadDocument().catch((error: unknown) => {
if (!isCancelled) {
console.error("Failed to render public resume PDF with PDF.js", error);
setError(true);
}
});
return () => {
isCancelled = true;
eventBus.off("pagesinit", setInitialScale);
eventBus.off("pagesloaded", syncViewerHeight);
eventBus.off("pagerendered", syncViewerHeight);
abortController.abort();
window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
if (pdfViewer) clearPdfViewerDocument(pdfViewer);
void pdfDocument?.destroy();
void loadingTask?.destroy();
viewer.replaceChildren();
};
}, [file]);
return (
<div
ref={rootRef}
className={cn("pdf-viewer relative bg-neutral-100", viewerHeight ? "min-h-0" : "min-h-48", className)}
style={viewerHeight ? { height: viewerHeight } : undefined}
>
<div ref={containerRef} className="absolute inset-0 overflow-visible">
<div ref={viewerRef} className="pdfViewer" />
</div>
{error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background px-6 text-center text-muted-foreground text-sm">
Unable to display PDF preview.
</div>
) : isReady ? null : (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Spinner className="size-6" />
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const publicResumeMock = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
downloadWithAnchor: vi.fn(),
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
PdfViewer: vi.fn<(_props: { className?: string; data: ResumeData }) => ReactNode>(() => null),
resume: undefined as
| undefined
| {
data: ResumeData;
name: string;
slug: string;
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: publicResumeMock.resume }),
}));
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useParams: () => ({ username: "amruth", slug: "sample" }),
}),
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor: publicResumeMock.downloadWithAnchor,
generateFilename: publicResumeMock.generateFilename,
}));
vi.mock("./pdf-viewer", () => ({
PdfViewer: publicResumeMock.PdfViewer,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
}));
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
}));
const { PublicResumeRoute } = await import("./public-resume");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
publicResumeMock.resume = {
data: sampleResumeData,
name: "Sample Resume",
slug: "sample",
};
publicResumeMock.PdfViewer.mockClear();
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
<div className={className} data-testid="pdf-viewer" />
));
});
const renderPublicResumeRoute = () =>
render(
<I18nProvider i18n={i18n}>
<PublicResumeRoute />
</I18nProvider>,
);
describe("PublicResumeRoute", () => {
it("renders the public resume through the route-local PDF.js viewer", () => {
renderPublicResumeRoute();
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({ data: sampleResumeData }),
undefined,
);
});
it("lets the public resume page grow to the full PDF length", () => {
renderPublicResumeRoute();
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
const page = viewerFrame?.parentElement;
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
});
});
@@ -0,0 +1,69 @@
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 { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { orpc } from "@/libs/orpc/client";
import { PdfViewer } from "./pdf-viewer";
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 flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
<div className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer data={resume.data} className="block w-full" />
</div>
<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,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 };
}
@@ -0,0 +1,172 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { KeyIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 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 { usePrompt } from "@/hooks/use-prompt";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
export function PasskeysSection() {
const queryClient = useQueryClient();
const prompt = usePrompt();
const { data: passkeys = [] } = useQuery({
queryKey: ["auth", "passkeys"],
queryFn: () => authClient.passkey.listUserPasskeys(),
select: ({ data }) => data ?? [],
});
const registerPasskeyMutation = useMutation({
mutationFn: async () => {
return await authClient.passkey.addPasskey();
},
onSuccess: async ({ data, error }) => {
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when passkey registration fails",
message: "Failed to register passkey. Please try again.",
}),
),
);
return;
}
toast.success(t`Passkey registered successfully.`);
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
const name = await prompt(t`Enter a name for your passkey.`, {
description: t`This will help you identify it later, if you plan to have multiple passkeys.`,
defaultValue: "",
confirmText: t({
comment: "Passkey rename prompt confirm action in authentication settings",
message: "Save",
}),
});
if (name === null) return;
const passkeyId = typeof data?.id === "string" ? data.id : null;
const passkeyName = name.trim();
if (!passkeyId || passkeyName.length === 0) return;
const { error: renameError } = await authClient.passkey.updatePasskey({ id: passkeyId, name: passkeyName });
if (renameError) {
toast.error(
getReadableErrorMessage(
renameError,
t({
comment: "Fallback toast when renaming a passkey fails",
message: "Failed to rename passkey. Please try again.",
}),
),
);
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.error(t`Failed to register passkey. Please try again.`);
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
return await authClient.passkey.deletePasskey({ id });
},
onSuccess: async ({ error }) => {
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when deleting a passkey fails",
message: "Failed to delete passkey. Please try again.",
}),
),
);
return;
}
toast.success(t`Passkey deleted successfully.`);
await queryClient.invalidateQueries({ queryKey: ["auth", "passkeys"] });
},
onError: () => {
toast.error(t`Failed to delete passkey. Please try again.`);
},
});
const handleRegisterPasskey = () => {
if (registerPasskeyMutation.isPending) return;
registerPasskeyMutation.mutate();
};
const handleDeletePasskey = (id: string) => {
if (deletePasskeyMutation.isPending) return;
deletePasskeyMutation.mutate(id);
};
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<Separator />
<div className="mt-4 grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-x-3 font-medium text-base">
<KeyIcon />
<Trans>Passkeys</Trans>
</h2>
<Button variant="outline" onClick={handleRegisterPasskey} disabled={registerPasskeyMutation.isPending}>
<PlusIcon />
<Trans>Register New Device</Trans>
</Button>
</div>
{passkeys.length === 0 && (
<p className="text-muted-foreground text-sm">
<Trans>No passkeys registered yet.</Trans>
</p>
)}
{passkeys.length > 0 && (
<div className="grid gap-2">
{passkeys.map((passkey) => {
return (
<div
key={passkey.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2"
>
<p className="truncate font-medium text-sm">{passkey.name ?? t`Unnamed passkey`}</p>
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDeletePasskey(passkey.id)}
disabled={deletePasskeyMutation.isPending}
>
<TrashIcon />
<Trans comment="Passkey row action to remove the selected passkey">Delete</Trans>
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
</motion.div>
);
}
@@ -0,0 +1,73 @@
import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useCallback, useMemo } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { useAuthAccounts } from "./hooks";
export function PasswordSection() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
const handleUpdatePassword = useCallback(() => {
if (hasPassword) {
openDialog("auth.change-password", undefined);
} else {
void navigate({ to: "/auth/forgot-password" });
}
}, [hasPassword, navigate, openDialog]);
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
>
<h2 className="flex items-center gap-x-3 font-medium text-base">
<PasswordIcon />
<Trans>Password</Trans>
</h2>
{match(hasPassword)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={handleUpdatePassword}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
</motion.div>
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button
variant="outline"
nativeButton={false}
render={
<Link to="/auth/forgot-password">
<Trans>Set Password</Trans>
</Link>
}
/>
</motion.div>
))
.exhaustive()}
</motion.div>
);
}
@@ -0,0 +1,84 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useCallback, useMemo } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
type SocialProviderSectionProps = {
provider: AuthProvider;
name?: string;
animationDelay?: number;
};
export function SocialProviderSection({ provider, name, animationDelay = 0 }: SocialProviderSectionProps) {
const { link, unlink } = useAuthProviderActions();
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
const providerName = useMemo(() => name ?? getProviderName(provider), [name, provider]);
const providerIcon = useMemo(() => getProviderIcon(provider), [provider]);
const account = useMemo(() => getAccountByProviderId(provider), [getAccountByProviderId, provider]);
const isConnected = useMemo(() => hasAccount(provider), [hasAccount, provider]);
const handleLink = useCallback(async () => {
await link(provider);
}, [link, provider]);
const handleUnlink = useCallback(async () => {
if (!account?.accountId) return;
await unlink(provider, account.accountId);
}, [account, unlink, provider]);
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
{providerIcon}
{providerName}
</h2>
{match(isConnected)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={handleUnlink}>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
</motion.div>
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={handleLink}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
</motion.div>
))
.exhaustive()}
</div>
</motion.div>
);
}
@@ -0,0 +1,76 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { useCallback, useMemo } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useDialogStore } from "@/dialogs/store";
import { authClient } from "@/libs/auth/client";
import { useAuthAccounts } from "./hooks";
export function TwoFactorSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const { data: session } = authClient.useSession();
const hasPassword = useMemo(() => hasAccount("credential"), [hasAccount]);
const hasTwoFactor = useMemo(() => session?.user.twoFactorEnabled ?? false, [session]);
const handleTwoFactorAction = useCallback(() => {
if (hasTwoFactor) {
openDialog("auth.two-factor.disable", undefined);
} else {
openDialog("auth.two-factor.enable", undefined);
}
}, [hasTwoFactor, openDialog]);
if (!hasPassword) return null;
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
{hasTwoFactor ? <LockOpenIcon /> : <KeyIcon />}
<Trans>Two-Factor Authentication</Trans>
</h2>
{match(hasTwoFactor)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleLeftIcon />
<Trans>Disable 2FA</Trans>
</Button>
</motion.div>
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleRightIcon />
<Trans>Enable 2FA</Trans>
</Button>
</motion.div>
))
.exhaustive()}
</div>
</motion.div>
);
}
@@ -0,0 +1,35 @@
import { motion } from "motion/react";
import { useEnabledProviders } from "./components/hooks";
import { PasskeysSection } from "./components/passkeys";
import { PasswordSection } from "./components/password";
import { SocialProviderSection } from "./components/social-provider";
import { TwoFactorSection } from "./components/two-factor";
export function AuthenticationSettingsPage() {
const { enabledProviders } = useEnabledProviders();
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
>
<PasswordSection />
<TwoFactorSection />
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
)}
</motion.div>
);
}
@@ -0,0 +1,427 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { CheckCircleIcon, KeyIcon, PlusIcon, TrashIcon, WarningCircleIcon, XCircleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AI_PROVIDER_DEFAULT_BASE_URLS } from "@reactive-resume/ai/types";
import { Badge } from "@reactive-resume/ui/components/badge";
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 { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
const providerOptions: AIProviderOption[] = [
{
value: "openai",
label: t`OpenAI`,
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
},
{
value: "anthropic",
label: t`Anthropic Claude`,
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
},
{
value: "gemini",
label: t`Google Gemini`,
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
},
{
value: "vercel-ai-gateway",
label: t`Vercel AI Gateway`,
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
},
{
value: "openrouter",
label: t`OpenRouter`,
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
},
{
value: "ollama",
label: t`Ollama`,
keywords: ["ollama", "local"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
},
{
value: "openai-compatible",
label: t`OpenAI-compatible`,
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
},
];
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
model: "",
baseURL: "",
apiKey: "",
};
function statusBadge(provider: SavedProvider) {
if (provider.testStatus === "success") {
return (
<Badge className="bg-emerald-600 text-white">
<Trans>Tested</Trans>
</Badge>
);
}
if (provider.testStatus === "failure") {
return (
<Badge variant="destructive">
<Trans>Failed</Trans>
</Badge>
);
}
return (
<Badge variant="secondary">
<Trans>Untested</Trans>
</Badge>
);
}
function providerLabel(provider: AIProvider) {
return providerOptions.find((option) => option.value === provider)?.label ?? provider;
}
function isAiProviderConfigError(error: unknown) {
if (error instanceof ORPCError && error.code === "PRECONDITION_FAILED") return true;
if (!error || typeof error !== "object") return false;
const status = (error as { status?: unknown; code?: unknown }).status ?? (error as { code?: unknown }).code;
return status === "PRECONDITION_FAILED" || status === 412;
}
function ProviderRow({ provider }: { provider: SavedProvider }) {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutate: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutate: updateProvider, isPending: isUpdating } = useMutation(orpc.aiProviders.update.mutationOptions());
const { mutate: deleteProvider, isPending: isDeleting } = useMutation(orpc.aiProviders.delete.mutationOptions());
const isMutating = isTesting || isUpdating || isDeleting;
return (
<div className="grid gap-4 rounded-md border bg-card p-4 md:grid-cols-[1fr_auto]">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold">{provider.label}</h3>
{statusBadge(provider)}
{provider.enabled ? (
<Badge variant="outline">
<Trans>Enabled</Trans>
</Badge>
) : null}
</div>
<div className="grid gap-1 text-muted-foreground text-sm">
<p>
{providerLabel(provider.provider)} · {provider.model}
</p>
<p className="truncate">{provider.baseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider.provider]}</p>
<p>
<Trans>Key</Trans>: {provider.apiKeyPreview}
</p>
{provider.testError ? <p className="text-rose-600">{provider.testError}</p> : null}
</div>
</div>
<div className="flex items-center gap-2 md:justify-end">
<div className="flex items-center gap-2 pe-2">
<Switch
checked={provider.enabled}
disabled={provider.testStatus !== "success" || isMutating}
onCheckedChange={(enabled) =>
updateProvider(
{ id: provider.id, enabled },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to update provider.` })),
},
)
}
/>
<span className="text-muted-foreground text-sm">
<Trans>Use</Trans>
</span>
</div>
<Button
variant="outline"
disabled={isMutating}
onClick={() =>
testProvider(
{ id: provider.id },
{
onSuccess: (response) => {
if (response.testStatus === "success") {
toast.success(t`Provider connection verified.`);
} else {
toast.error(response.testError ?? t`Could not verify provider connection.`);
}
void invalidate();
},
onError: (error) => {
toast.error(getOrpcErrorMessage(error, { fallback: t`Could not verify provider connection.` }));
void invalidate();
},
},
)
}
>
{isTesting ? <Spinner /> : provider.testStatus === "success" ? <CheckCircleIcon /> : <WarningCircleIcon />}
<Trans>Test</Trans>
</Button>
<Button
size="icon"
variant="ghost"
disabled={isMutating}
onClick={() =>
deleteProvider(
{ id: provider.id },
{
onSuccess: () => void invalidate(),
onError: (error) =>
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to delete provider.` })),
},
)
}
>
<TrashIcon />
<span className="sr-only">
<Trans>Delete provider</Trans>
</span>
</Button>
</div>
</div>
);
}
function CreateProviderForm() {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyForm);
const selectedOption = useMemo(
() => providerOptions.find((option) => option.value === form.provider),
[form.provider],
);
const canCreate = form.label.trim() && form.model.trim() && form.apiKey.trim();
const { mutate: createProvider, isPending } = useMutation(orpc.aiProviders.create.mutationOptions());
return (
<div className="rounded-md border bg-card p-4">
<div className="mb-4 flex items-center gap-2">
<div className="grid size-8 place-items-center rounded-md bg-primary/10 text-primary">
<PlusIcon />
</div>
<h3 className="font-semibold">
<Trans>Add Provider</Trans>
</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={t`Work OpenAI`}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
</Label>
<Combobox
id="ai-provider"
value={form.provider}
showClear={false}
options={providerOptions}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider }));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
<Input
id="ai-api-key"
type="password"
value={form.apiKey}
onChange={(event) => setForm((current) => ({ ...current, apiKey: event.target.value }))}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
data-lpignore="true"
data-bwignore="true"
data-1p-ignore="true"
/>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
disabled={!canCreate || isPending}
onClick={() =>
createProvider(
{
label: form.label.trim(),
provider: form.provider,
model: form.model.trim(),
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
},
{
onSuccess: () => {
setForm(emptyForm);
toast.success(t`AI provider saved. Test it before use.`);
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
),
},
)
}
>
{isPending ? <Spinner /> : <KeyIcon />}
<Trans>Save Provider</Trans>
</Button>
</div>
</div>
);
}
export function AISettingsSection() {
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const hasUsableProvider = providers?.some((provider) => provider.enabled && provider.testStatus === "success");
const isConfigError = isAiProviderConfigError(error);
return (
<section className="grid gap-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold text-lg">
<Trans>AI Providers</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>API keys are encrypted on the server and never shown again after saving.</Trans>
</p>
</div>
<p className="flex items-center gap-2 text-sm">
{hasUsableProvider ? (
<CheckCircleIcon className="text-emerald-600" />
) : (
<XCircleIcon className="text-rose-600" />
)}
<span className={cn(hasUsableProvider ? "text-emerald-700" : "text-muted-foreground")}>
{hasUsableProvider ? <Trans>Agent ready</Trans> : <Trans>No tested provider</Trans>}
</span>
</p>
</div>
{error ? (
<div
className={cn(
"rounded-md border p-4 text-sm",
isConfigError
? "border-amber-300 bg-amber-50 text-amber-950 dark:bg-amber-950/20 dark:text-amber-200"
: "border-rose-300 bg-rose-50 text-rose-950 dark:bg-rose-950/20 dark:text-rose-200",
)}
>
{isConfigError ? (
<Trans>AI provider management is unavailable until REDIS_URL and ENCRYPTION_SECRET are configured.</Trans>
) : (
<Trans>AI provider management is unavailable. Please try again.</Trans>
)}
</div>
) : null}
{error ? null : <CreateProviderForm />}
<div className="grid gap-3">
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Spinner />
<Trans>Loading providers...</Trans>
</div>
) : null}
{providers?.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm">
<Trans>Add and test a provider before starting an agent thread.</Trans>
</div>
) : null}
{providers?.map((provider) => (
<ProviderRow key={provider.id} provider={provider} />
))}
</div>
</section>
);
}
@@ -0,0 +1,20 @@
import { motion } from "motion/react";
import { useIsClient } from "usehooks-ts";
import { AISettingsSection } from "./components/ai-section";
export function IntegrationsSettingsPage() {
const isClient = useIsClient();
if (!isClient) return null;
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
>
<AISettingsSection />
</motion.div>
);
}
@@ -0,0 +1,152 @@
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 { 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";
export function ApiKeysSettingsPage() {
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 (
<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>
);
}
@@ -0,0 +1,95 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TrashSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
const CONFIRMATION_TEXT = "delete";
export function DangerZoneSettingsPage() {
const confirm = useConfirm();
const navigate = useNavigate();
const [confirmationText, setConfirmationText] = useState("");
const isConfirmationValid = confirmationText === CONFIRMATION_TEXT;
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
const handleDeleteAccount = async () => {
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
description: t`This action cannot be undone. All your data will be permanently deleted.`,
confirmText: t({
comment: "Account deletion confirmation dialog confirm action in danger zone",
message: "Confirm",
}),
cancelText: t({
comment: "Account deletion confirmation dialog cancel action in danger zone",
message: "Cancel",
}),
});
if (!confirmed) return;
const toastId = toast.loading(t`Deleting your account...`);
deleteAccount(undefined, {
onSuccess: async () => {
toast.success(t`Your account has been deleted successfully.`, { id: toastId });
await authClient.signOut();
void navigate({ to: "/" });
},
onError: (error) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when account deletion fails",
message: "Failed to delete your account. Please try again.",
}),
),
{ id: toastId },
);
},
});
};
return (
<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]"
>
<p className="leading-relaxed">
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
</p>
<Input
type="text"
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
/>
<motion.div
className="justify-self-end will-change-transform"
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
<TrashSimpleIcon />
<Trans>Delete Account</Trans>
</Button>
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,44 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { LocaleCombobox } from "@/features/locale/combobox";
import { ThemeCombobox } from "@/features/theme/combobox";
export function PreferencesSettingsPage() {
return (
<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="grid gap-1.5">
<Label className="mb-0.5">
<Trans>Theme</Trans>
</Label>
<ThemeCombobox />
</div>
<div className="grid gap-1.5">
<Label className="mb-0.5">
<Trans>Language</Trans>
</Label>
<LocaleCombobox />
<Button
size="sm"
variant="link"
nativeButton={false}
className="h-5 justify-start text-muted-foreground text-xs active:scale-100"
render={
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
<Trans>Help translate the app to your language</Trans>
<ArrowRightIcon className="size-3" />
</a>
}
/>
</div>
</motion.div>
);
}
@@ -0,0 +1,268 @@
import type { AuthSession } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useRouter } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/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 { getReadableErrorMessage } from "@/libs/error-message";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
name: z.string().trim().min(1).max(64),
username: z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9._-]+$/, {
message: "Username can only contain lowercase letters, numbers, dots, hyphens and underscores.",
}),
email: z.email().trim(),
});
type Props = {
session: AuthSession;
};
export function ProfileSettingsPage({ session }: Props) {
const router = useRouter();
const form = useAppForm({
defaultValues: {
name: session.user.name,
username: session.user.username,
email: session.user.email,
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
const { error } = await authClient.updateUser({
name: value.name,
username: value.username,
displayUsername: value.username,
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when updating profile details fails",
message: "Failed to update your profile. Please try again.",
}),
),
);
return;
}
toast.success(t`Your profile has been updated successfully.`);
form.reset({ name: value.name, username: value.username, email: session.user.email });
void router.invalidate();
if (value.email !== session.user.email) {
const { error } = await authClient.changeEmail({
newEmail: value.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when requesting email change confirmation fails",
message: "Failed to request email change. Please try again.",
}),
),
);
return;
}
toast.success(
t`A confirmation link has been sent to your current email address. Please check your inbox to confirm the change.`,
);
form.reset({ name: value.name, username: value.username, email: session.user.email });
void router.invalidate();
}
},
});
const onCancel = () => {
form.reset();
};
const isDirty = useStore(form.store, (s) => s.isDirty);
const handleResendVerificationEmail = async () => {
const toastId = toast.loading(t`Resending verification email...`);
const { error } = await authClient.sendVerificationEmail({
email: session.user.email,
callbackURL: "/dashboard/settings/profile",
});
if (error) {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when resending account verification email fails",
message: "Failed to resend verification email. Please try again.",
}),
),
{ id: toastId },
);
return;
}
toast.success(
t`A new verification link has been sent to your email address. Please check your inbox to verify your account.`,
{ id: toastId },
);
void router.invalidate();
};
return (
<motion.form
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]"
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
min={3}
max={64}
autoComplete="name"
placeholder={t({
comment: "Example full name placeholder on profile settings 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>Username</Trans>
</FormLabel>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="username"
placeholder={t({
comment: "Example username placeholder on profile settings 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>Email Address</Trans>
</FormLabel>
<FormControl
render={
<Input
type="email"
autoComplete="email"
placeholder={t({
comment: "Example email placeholder on profile settings 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} />
{session.user.emailVerified === true ? (
<p className="flex items-center gap-x-1.5 text-green-700 text-xs">
<CheckIcon />
<Trans>Verified</Trans>
</p>
) : (
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
<WarningIcon className="size-3.5" />
<Trans>Unverified</Trans>
<span>|</span>
<Button
variant="link"
className="h-auto gap-x-1.5 p-0! text-inherit text-xs"
onClick={handleResendVerificationEmail}
>
<Trans>Resend verification email</Trans>
</Button>
</p>
)}
</FormItem>
)}
</form.Field>
<AnimatePresence initial={false} mode="popLayout">
{isDirty && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Save Changes</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.form>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { useLingui } from "@lingui/react";
import { useRouter } from "@tanstack/react-router";
import { Combobox } from "@/components/ui/combobox";
import { isTheme, themeMap } from "@/libs/theme";
import { useTheme } from "./provider";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export function ThemeCombobox(props: Props) {
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
const options = Object.entries(themeMap).map(([value, label]) => ({
value,
label: i18n.t(label),
keywords: [i18n.t(label)],
}));
const onThemeChange = async (value: string | null) => {
if (!value || !isTheme(value)) return;
setTheme(value);
void router.invalidate();
};
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
}
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// ThemeProvider depends on TanStack Start helpers + server fn — stub them.
vi.mock("@tanstack/react-router", () => ({
useRouter: () => ({ invalidate: vi.fn() }),
}));
vi.mock("@/libs/theme", () => ({
setThemeServerFn: vi.fn().mockResolvedValue(undefined),
}));
const { ThemeProvider, useTheme } = await import("./provider");
describe("useTheme", () => {
it("throws when used outside ThemeProvider", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useTheme())).toThrow(/useTheme must be used within a ThemeProvider/);
consoleError.mockRestore();
});
it("returns the theme and helpers when wrapped in ThemeProvider", () => {
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>,
});
expect(result.current.theme).toBe("dark");
expect(typeof result.current.setTheme).toBe("function");
expect(typeof result.current.toggleTheme).toBe("function");
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { PropsWithChildren } from "react";
import type { Theme } from "@/libs/theme";
import { useRouter } from "@tanstack/react-router";
import { createContext, use, useEffect } from "react";
import { setThemeCookie } from "@/libs/theme";
type ThemeContextValue = {
theme: Theme;
setTheme: (value: Theme, options?: { playSound?: boolean }) => void;
toggleTheme: (options?: { playSound?: boolean }) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
type Props = PropsWithChildren<{ theme: Theme }>;
export function ThemeProvider({ children, theme }: Props) {
const router = useRouter();
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
const { playSound = true } = options;
document.documentElement.classList.toggle("dark", value === "dark");
setThemeCookie(value);
void router.invalidate();
if (!playSound) return;
try {
const soundClip = value === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
const audio = new Audio(soundClip);
await audio.play();
} catch {
// ignore errors
}
}
function toggleTheme(options: { playSound?: boolean } = {}) {
void setTheme(theme === "dark" ? "light" : "dark", options);
}
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const value = use(ThemeContext);
if (!value) throw new Error("useTheme must be used within a ThemeProvider");
return value;
}
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
const toggleTheme = vi.hoisted(() => vi.fn());
vi.mock("./provider", () => ({
useTheme: () => ({ theme: "light", setTheme: vi.fn(), toggleTheme }),
}));
const { ThemeToggleButton } = await import("./toggle-button");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
toggleTheme.mockReset();
// Reset prefers-reduced-motion + startViewTransition stub between tests.
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
// biome-ignore lint/suspicious/noExplicitAny: removing a non-standard API stub
(document as any).startViewTransition = undefined;
});
describe("ThemeToggleButton", () => {
it("renders a button with an aria-label that flips with the theme", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button");
expect(button?.getAttribute("aria-label")).toBe("Switch to dark theme");
});
it("calls toggleTheme directly when the view-transition API is unavailable", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(button);
expect(toggleTheme).toHaveBeenCalledTimes(1);
});
it("calls toggleTheme directly when prefers-reduced-motion is set", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
const startVT = vi.fn();
Object.defineProperty(document, "startViewTransition", {
writable: true,
configurable: true,
value: startVT,
});
const { container } = render(<ThemeToggleButton />);
fireEvent.click(container.querySelector("button") as HTMLButtonElement);
expect(toggleTheme).toHaveBeenCalledTimes(1);
expect(startVT).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { t } from "@lingui/core/macro";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import { Button } from "@reactive-resume/ui/components/button";
import { useTheme } from "./provider";
export function ThemeToggleButton(props: React.ComponentProps<typeof Button>) {
const { theme, toggleTheme } = useTheme();
const buttonRef = useRef<HTMLButtonElement>(null);
const onToggleTheme = useCallback(async () => {
if (
!buttonRef.current ||
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
toggleTheme();
return;
}
let timeout: NodeJS.Timeout;
const style = document.createElement("style");
style.textContent = `
::view-transition-old(root), ::view-transition-new(root) {
mix-blend-mode: normal !important;
animation: none !important;
}
`;
function transitionCallback() {
flushSync(() => {
toggleTheme();
timeout = setTimeout(() => {
clearTimeout(timeout);
document.head.removeChild(style);
}, 1000);
});
}
document.head.appendChild(style);
await document.startViewTransition(transitionCallback).ready;
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
);
}, [toggleTheme]);
const ariaLabel = theme === "dark" ? t`Switch to light theme` : t`Switch to dark theme`;
return (
<Button size="icon" variant="ghost" ref={buttonRef} onClick={onToggleTheme} aria-label={ariaLabel} {...props}>
{theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
</Button>
);
}
@@ -0,0 +1,128 @@
import type { AuthSession } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { PaletteIcon, SignOutIcon, TranslateIcon } from "@phosphor-icons/react";
import { useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useTheme } from "@/features/theme/provider";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { isTheme } from "@/libs/theme";
type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
const { data: session } = authClient.useSession();
const handleThemeChange = (value: string) => {
if (!isTheme(value)) return;
setTheme(value);
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
const handleLogout = async () => {
const toastId = toast.loading(t`Signing out...`);
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
toast.dismiss(toastId);
void router.invalidate();
},
onError: ({ error }) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when signing out fails",
message: "Failed to sign out. Please try again.",
}),
),
{ id: toastId },
);
},
},
});
};
if (!isClient) return null;
if (!session?.user) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={children({ session: session as AuthSession })} />
<DropdownMenuContent align="start" side="top">
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<TranslateIcon />
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
{Object.entries(localeMap).map(([value, label]) => (
<DropdownMenuRadioItem key={value} value={value}>
{i18n.t(label)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon />
<Trans comment="Menu item that opens appearance theme selection submenu">Theme</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>
<DropdownMenuRadioItem value="light">
<Trans comment="Appearance theme option for light mode">Light</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Trans comment="Appearance theme option for dark mode">Dark</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<SignOutIcon />
<Trans comment="User menu action to sign out of current account">Logout</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}