mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
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:
@@ -0,0 +1,189 @@
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type SocialAuthProps = {
|
||||
requestSignUp?: boolean;
|
||||
};
|
||||
|
||||
type SocialSignInOptions = {
|
||||
provider: string;
|
||||
callbackURL: string;
|
||||
requestSignUp?: true;
|
||||
};
|
||||
|
||||
function getSocialSignInOptions(provider: string, requestSignUp: boolean): SocialSignInOptions {
|
||||
const options: SocialSignInOptions = { provider, callbackURL: "/dashboard" };
|
||||
if (requestSignUp) options.requestSignUp = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
export function SocialAuth({ requestSignUp = false }: SocialAuthProps) {
|
||||
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<hr className="flex-1" />
|
||||
<span className="font-medium text-xs tracking-wide">
|
||||
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
|
||||
or continue with
|
||||
</Trans>
|
||||
</span>
|
||||
<hr className="flex-1" />
|
||||
</div>
|
||||
|
||||
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} requestSignUp={requestSignUp} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialAuthSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SocialAuthButtonsProps = {
|
||||
providers: RouterOutput["auth"]["providers"]["list"];
|
||||
requestSignUp: boolean;
|
||||
};
|
||||
|
||||
function SocialAuthButtons({ providers, requestSignUp }: SocialAuthButtonsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleSocialLogin = async (provider: string) => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.social(getSocialSignInOptions(provider, requestSignUp));
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when social sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.oauth2({
|
||||
providerId: "custom",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when custom OAuth sign-in fails without a provider error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
const toastId = toast.loading(t`Signing in...`);
|
||||
|
||||
const { error } = await authClient.signIn.passkey({ autoFill: false });
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t({
|
||||
comment: "Fallback toast when passkey sign-in fails without an error message",
|
||||
message: "Failed to sign in. Please try again.",
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
await router.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOAuthLogin}
|
||||
className={cn("hidden", "custom" in providers && "inline-flex")}
|
||||
>
|
||||
<VaultIcon />
|
||||
{providers.custom}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePasskeyLogin}
|
||||
className={cn("hidden", "passkey" in providers && "inline-flex")}
|
||||
>
|
||||
<FingerprintIcon />
|
||||
<Trans comment="Label for passkey sign-in button">Passkey</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
|
||||
"google" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GoogleLogoIcon />
|
||||
<Trans comment="Brand name label for Google social sign-in button">Google</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("github")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
|
||||
"github" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<GithubLogoIcon />
|
||||
<Trans comment="Brand name label for GitHub social sign-in button">GitHub</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSocialLogin("linkedin")}
|
||||
className={cn(
|
||||
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
|
||||
"linkedin" in providers && "inline-flex",
|
||||
)}
|
||||
>
|
||||
<LinkedinLogoIcon />
|
||||
<Trans comment="Brand name label for LinkedIn social sign-in button">LinkedIn</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user