refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View File

View File

@ -0,0 +1,121 @@
import {
CaretDown,
ChatTeardropText,
CircleNotch,
Exam,
MagicWand,
PenNib,
} from "@phosphor-icons/react";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useState } from "react";
import { changeTone } from "../services/openai/change-tone";
import { fixGrammar } from "../services/openai/fix-grammar";
import { improveWriting } from "../services/openai/improve-writing";
import { useOpenAiStore } from "../stores/openai";
type Action = "improve" | "fix" | "tone";
type Mood = "casual" | "professional" | "confident" | "friendly";
type Props = {
value: string;
onChange: (value: string) => void;
className?: string;
};
export const AiActions = ({ value, onChange, className }: Props) => {
const [loading, setLoading] = useState<Action | false>(false);
const aiEnabled = useOpenAiStore((state) => !!state.apiKey);
if (!aiEnabled) return null;
const onClick = async (action: Action, mood?: Mood) => {
setLoading(action);
let result = value;
// await new Promise((resolve) => setTimeout(resolve, 2000));
if (action === "improve") result = await improveWriting(value);
if (action === "fix") result = await fixGrammar(value);
if (action === "tone" && mood) result = await changeTone(value, mood);
onChange("Result" + result);
setLoading(false);
};
return (
<div
className={cn(
"relative mt-4 rounded bg-secondary-accent/50 p-3 outline outline-secondary-accent",
"flex flex-wrap items-center justify-center gap-2",
className,
)}
>
<div className="absolute -left-5 z-10">
<Badge
outline
variant="primary"
className="-rotate-90 bg-background px-2 text-[10px] leading-[10px]"
>
<MagicWand size={10} className="mr-1" />
AI
</Badge>
</div>
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("improve")}>
{loading === "improve" ? <CircleNotch className="animate-spin" /> : <PenNib />}
<span className="ml-2 text-xs">Improve Writing</span>
</Button>
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("fix")}>
{loading === "fix" ? <CircleNotch className="animate-spin" /> : <Exam />}
<span className="ml-2 text-xs">Fix Spelling & Grammar</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={!!loading}>
{loading === "tone" ? <CircleNotch className="animate-spin" /> : <ChatTeardropText />}
<span className="mx-2 text-xs">Change Tone</span>
<CaretDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onClick("tone", "casual")}>
<span role="img" aria-label="Casual">
🙂
</span>
<span className="ml-2">Casual</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onClick("tone", "professional")}>
<span role="img" aria-label="Professional">
💼
</span>
<span className="ml-2">Professional</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onClick("tone", "confident")}>
<span role="img" aria-label="Confident">
😎
</span>
<span className="ml-2">Confident</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onClick("tone", "friendly")}>
<span role="img" aria-label="Friendly">
😊
</span>
<span className="ml-2">Friendly</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,34 @@
import { cn } from "@reactive-resume/utils";
type Props = {
className?: string;
};
export const Copyright = ({ className }: Props) => (
<div
className={cn(
"prose prose-sm prose-zinc flex max-w-none flex-col gap-y-1 text-xs opacity-40 dark:prose-invert",
className,
)}
>
<span>
Licensed under{" "}
<a
target="_blank"
rel="noopener noreferrer nofollow"
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
>
MIT
</a>
</span>
<span>By the community, for the community.</span>
<span>
A passion project by{" "}
<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.amruthpillai.com/">
Amruth Pillai
</a>
</span>
<span className="mt-2 font-bold">Reactive Resume v{appVersion}</span>
</div>
);

View File

@ -0,0 +1,32 @@
import { useTheme } from "@reactive-resume/hooks";
import { cn } from "@reactive-resume/utils";
type Props = {
size?: number;
className?: string;
};
export const Icon = ({ size = 32, className }: Props) => {
const { isDarkMode } = useTheme();
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) {
case false:
src = "/icon/dark.svg";
break;
case true:
src = "/icon/light.svg";
break;
}
return (
<img
src={src}
width={size}
height={size}
alt="Reactive Resume"
className={cn("rounded-sm", className)}
/>
);
};

View File

@ -0,0 +1,32 @@
import { useTheme } from "@reactive-resume/hooks";
import { cn } from "@reactive-resume/utils";
type Props = {
size?: number;
className?: string;
};
export const Logo = ({ size = 32, className }: Props) => {
const { isDarkMode } = useTheme();
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) {
case false:
src = "/logo/light.svg";
break;
case true:
src = "/logo/dark.svg";
break;
}
return (
<img
src={src}
width={size}
height={size}
alt="Reactive Resume"
className={cn("rounded-sm", className)}
/>
);
};

View File

@ -0,0 +1,33 @@
import { CloudSun, Moon, Sun } from "@phosphor-icons/react";
import { useTheme } from "@reactive-resume/hooks";
import { Button } from "@reactive-resume/ui";
import { motion, Variants } from "framer-motion";
import { useMemo } from "react";
type Props = {
size?: number;
};
export const ThemeSwitch = ({ size = 20 }: Props) => {
const { theme, toggleTheme } = useTheme();
const variants: Variants = useMemo(() => {
return {
light: { x: 0 },
system: { x: size * -1 },
dark: { x: size * -2 },
};
}, [size]);
return (
<Button size="icon" variant="ghost" onClick={toggleTheme}>
<div className="cursor-pointer overflow-hidden" style={{ width: size, height: size }}>
<motion.div animate={theme} variants={variants} className="flex">
<Sun size={size} className="shrink-0" />
<CloudSun size={size} className="shrink-0" />
<Moon size={size} className="shrink-0" />
</motion.div>
</div>
</Button>
);
};

View File

@ -0,0 +1,40 @@
import { getInitials } from "@reactive-resume/utils";
import { useUser } from "../services/user";
type Props = {
size?: number;
className?: string;
};
export const UserAvatar = ({ size = 36, className }: Props) => {
const { user } = useUser();
if (!user) return null;
let picture: React.ReactNode = null;
if (!user.picture) {
const initials = getInitials(user.name);
picture = (
<div
style={{ width: size, height: size }}
className="flex items-center justify-center rounded-full bg-secondary text-center text-[10px] font-semibold text-secondary-foreground"
>
{initials}
</div>
);
} else {
picture = (
<img
alt={user.name}
src={user.picture}
className="rounded-full"
style={{ width: size, height: size }}
/>
);
}
return <div className={className}>{picture}</div>;
};

View File

@ -0,0 +1,38 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
KeyboardShortcut,
} from "@reactive-resume/ui";
import { useNavigate } from "react-router-dom";
import { useLogout } from "../services/auth";
type Props = {
children: React.ReactNode;
};
export const UserOptions = ({ children }: Props) => {
const navigate = useNavigate();
const { logout } = useLogout();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-48">
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")}>
Settings
<KeyboardShortcut>S</KeyboardShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
Logout
<KeyboardShortcut>Q</KeyboardShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,20 @@
export const colors: string[] = [
"#78716c", // stone-500
"#ef4444", // red-500
"#f97316", // orange-500
"#f59e0b", // amber-500
"#eab308", // yellow-500
"#84cc16", // lime-500
"#22c55e", // green-500
"#10b981", // emerald-500
"#14b8a6", // teal-500
"#06b6d4", // cyan-500
"#0ea5e9", // sky-500
"#3b82f6", // blue-500
"#6366f1", // indigo-500
"#8b5cf6", // violet-500
"#a855f7", // purple-500
"#d946ef", // fuchsia-500
"#ec4899", // pink-500
"#f43f5e", // rose-500
];

View File

@ -0,0 +1,11 @@
import { ReactParallaxTiltProps } from "react-parallax-tilt";
export const defaultTiltProps: ReactParallaxTiltProps = {
scale: 1.05,
tiltMaxAngleX: 8,
tiltMaxAngleY: 8,
perspective: 1400,
glareEnable: true,
glareMaxOpacity: 0.1,
glareColor: "#fafafa",
};

View File

@ -0,0 +1,7 @@
import { QueryKey } from "@tanstack/react-query";
export const USER_KEY: QueryKey = ["user"];
export const RESUME_KEY: QueryKey = ["resume"];
export const RESUMES_KEY: QueryKey = ["resumes"];
export const RESUME_PREVIEW_KEY: QueryKey = ["resume", "preview"];

View File

@ -0,0 +1,177 @@
import { createId } from "@paralleldrive/cuid2";
import { ToastActionElement, ToastProps } from "@reactive-resume/ui";
import { useEffect, useState } from "react";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 5000;
type ToasterToast = ToastProps & {
id: string;
icon?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = createId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = useState<State>(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { toast, useToast };

View File

@ -0,0 +1,53 @@
import { deepSearchAndParseDates } from "@reactive-resume/utils";
import _axios from "axios";
import createAuthRefreshInterceptor from "axios-auth-refresh";
import { redirect } from "react-router-dom";
import { USER_KEY } from "../constants/query-keys";
import { refresh } from "../services/auth/refresh";
import { queryClient } from "./query-client";
export type ServerError = {
statusCode: number;
message: string;
error: string;
};
export const axios = _axios.create({ baseURL: "/api", withCredentials: true });
// Intercept responses to transform ISO dates to JS date objects
axios.interceptors.response.use((response) => {
const transformedResponse = deepSearchAndParseDates(response.data, ["createdAt", "updatedAt"]);
return { ...response, data: transformedResponse };
});
// Create another instance to handle failed refresh tokens
// Reference: https://github.com/Flyrell/axios-auth-refresh/issues/191
const axiosForRefresh = _axios.create({ baseURL: "/api", withCredentials: true });
// Interceptor to handle expired access token errors
const handleAuthError = async () => {
try {
await refresh(axiosForRefresh);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};
// Interceptor to handle expired refresh token errors
const handleRefreshError = async () => {
try {
queryClient.invalidateQueries({ queryKey: USER_KEY });
redirect("/auth/login");
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};
// Intercept responses to check for 401 and 403 errors, refresh token and retry the request
createAuthRefreshInterceptor(axios, handleAuthError, { statusCodes: [401, 403] });
createAuthRefreshInterceptor(axiosForRefresh, handleRefreshError);

View File

@ -0,0 +1,6 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);

View File

@ -0,0 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: false,
},
queries: {
retry: false,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: 1000 * 60, // 1 minute
},
},
});

13
apps/client/src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import * as ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@ -0,0 +1,20 @@
import { GithubLogo, GoogleLogo } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui";
export const SocialAuth = () => (
<div className="grid grid-cols-2 gap-4">
<Button asChild size="lg" className="w-full !bg-[#222] !text-white hover:!bg-[#222]/80">
<a href="/api/auth/github">
<GoogleLogo className="mr-3 h-4 w-4" />
GitHub
</a>
</Button>
<Button asChild size="lg" className="w-full !bg-[#4285F4] !text-white hover:!bg-[#4285F4]/80">
<a href="/api/auth/google">
<GithubLogo className="mr-3 h-4 w-4" />
Google
</a>
</Button>
</div>
);

View File

@ -0,0 +1,102 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Warning } from "@phosphor-icons/react";
import { twoFactorBackupSchema } from "@reactive-resume/dto";
import { usePasswordToggle } from "@reactive-resume/hooks";
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { toast } from "@/client/hooks/use-toast";
import { useBackupOtp } from "@/client/services/auth";
type FormValues = z.infer<typeof twoFactorBackupSchema>;
export const BackupOtpPage = () => {
const navigate = useNavigate();
const { backupOtp, loading } = useBackupOtp();
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
const form = useForm<FormValues>({
resolver: zodResolver(twoFactorBackupSchema),
defaultValues: { code: "" },
});
const onSubmit = async (data: FormValues) => {
try {
await backupOtp(data);
navigate("/dashboard");
} catch (error) {
form.reset();
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to sign in",
description: message,
});
}
}
};
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Use your backup code</h2>
<h6 className="leading-relaxed opacity-60">
Enter one of the 10 backup codes you saved when you enabled two-factor authentication.
</h6>
</div>
<div>
<Form {...form}>
<form
ref={formRef}
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="code"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Backup Code</FormLabel>
<FormControl>
<Input
pattern="[a-z0-9]{10}"
placeholder="a1b2c3d4e5"
title="may contain lowercase letters or numbers, and must be exactly 10 characters."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading} className="mt-4 w-full">
Sign in
</Button>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,105 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Warning } from "@phosphor-icons/react";
import { forgotPasswordSchema } from "@reactive-resume/dto";
import {
Alert,
AlertDescription,
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { toast } from "@/client/hooks/use-toast";
import { useForgotPassword } from "@/client/services/auth";
type FormValues = z.infer<typeof forgotPasswordSchema>;
export const ForgotPasswordPage = () => {
const [submitted, setSubmitted] = useState<boolean>(false);
const { forgotPassword, loading } = useForgotPassword();
const form = useForm<FormValues>({
resolver: zodResolver(forgotPasswordSchema),
defaultValues: { email: "" },
});
const onSubmit = async (data: FormValues) => {
try {
await forgotPassword(data);
setSubmitted(true);
form.reset();
} catch (error) {
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to send your password recovery email",
description: message,
});
}
}
};
if (submitted) {
return (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">You've got mail!</h2>
<Alert variant="success">
<AlertDescription className="pt-0">
A password reset link should have been sent to your inbox, if an account existed with
the email you provided.
</AlertDescription>
</Alert>
</div>
</div>
);
}
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Forgot your password?</h2>
<h6 className="leading-relaxed opacity-75">
Enter your email address and we will send you a link to reset your password if the account
exists.
</h6>
</div>
<div>
<Form {...form}>
<form className="flex flex-col gap-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading} className="mt-4 w-full">
Send Email
</Button>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,58 @@
import { useMemo } from "react";
import { Link, matchRoutes, Outlet, useLocation } from "react-router-dom";
import { Logo } from "@/client/components/logo";
import { SocialAuth } from "./_components/social-auth";
const authRoutes = [{ path: "/auth/login" }, { path: "/auth/register" }];
export const AuthLayout = () => {
const location = useLocation();
const isAuthRoute = useMemo(() => matchRoutes(authRoutes, location) !== null, [location]);
return (
<div className="flex h-screen w-screen">
<div className="flex w-full flex-col justify-center gap-y-8 px-12 sm:mx-auto sm:basis-[420px] sm:px-0 lg:basis-[480px] lg:px-12">
<Link to="/" className="h-24 w-24">
<Logo className="-ml-3" size={96} />
</Link>
<Outlet />
{isAuthRoute && (
<>
<div className="flex items-center gap-x-4">
<hr className="flex-1" />
<span className="text-xs font-medium">or continue with</span>
<hr className="flex-1" />
</div>
<SocialAuth />
</>
)}
</div>
<div className="relative hidden lg:block lg:flex-1">
<img
width={1920}
height={1080}
alt="Open books on a table"
className="h-screen w-full object-cover object-center"
src="/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg"
/>
<div className="absolute bottom-5 right-5 z-10 bg-primary/30 px-4 py-2 text-xs font-medium text-primary-foreground backdrop-blur-sm">
<a
href="https://unsplash.com/photos/Oaqk7qqNh_c"
target="_blank"
rel="noopener noreferrer nofollow"
>
Photograph by Patrick Tomasso
</a>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,126 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Warning } from "@phosphor-icons/react";
import { loginSchema } from "@reactive-resume/dto";
import { usePasswordToggle } from "@reactive-resume/hooks";
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { Link } from "react-router-dom";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useLogin } from "@/client/services/auth";
type FormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => {
const { toast } = useToast();
const { login, loading } = useLogin();
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
const form = useForm<FormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { identifier: "", password: "" },
});
const onSubmit = async (data: FormValues) => {
try {
await login(data);
} catch (error) {
form.reset();
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to sign in",
description: message,
});
}
}
};
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Sign in to your account</h2>
<h6>
<span className="opacity-75">Don't have an account?</span>
<Button asChild variant="link" className="px-1.5">
<Link to="/auth/register">
Create one now <ArrowRight className="ml-1" />
</Link>
</Button>
</h6>
</div>
<div>
<Form {...form}>
<form
ref={formRef}
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="identifier"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormDescription>You can also enter your username.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
temporarily.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex items-center gap-x-4">
<Button type="submit" disabled={loading} className="flex-1">
Sign in
</Button>
<Button asChild variant="link" className="px-4">
<Link to="/auth/forgot-password">Forgot Password?</Link>
</Button>
</div>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,155 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Warning } from "@phosphor-icons/react";
import { registerSchema } from "@reactive-resume/dto";
import { usePasswordToggle } from "@reactive-resume/hooks";
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { z } from "zod";
import { toast } from "@/client/hooks/use-toast";
import { useRegister } from "@/client/services/auth";
type FormValues = z.infer<typeof registerSchema>;
export const RegisterPage = () => {
const navigate = useNavigate();
const { register, loading } = useRegister();
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
const form = useForm<FormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
username: "",
email: "",
password: "",
language: "en",
},
});
const onSubmit = async (data: FormValues) => {
try {
await register(data);
navigate("/auth/verify-email");
} catch (error) {
form.reset();
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to sign up",
description: message,
});
}
}
};
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Create a new account</h2>
<h6>
<span className="opacity-75">Already have an account?</span>
<Button asChild variant="link" className="px-1.5">
<Link to="/auth/login">
Sign in now <ArrowRight className="ml-1" />
</Link>
</Button>
</h6>
</div>
<div>
<Form {...form}>
<form
ref={formRef}
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="username"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="john.doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
temporarily.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button disabled={loading} className="mt-4 w-full">
Sign up
</Button>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,110 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Warning } from "@phosphor-icons/react";
import { resetPasswordSchema } from "@reactive-resume/dto";
import { usePasswordToggle } from "@reactive-resume/hooks";
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { useNavigate, useSearchParams } from "react-router-dom";
import { z } from "zod";
import { toast } from "@/client/hooks/use-toast";
import { useResetPassword } from "@/client/services/auth";
type FormValues = z.infer<typeof resetPasswordSchema>;
export const ResetPasswordPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") || "";
const { resetPassword, loading } = useResetPassword();
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
const form = useForm<FormValues>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: { token, password: "" },
});
const onSubmit = async (data: FormValues) => {
try {
await resetPassword(data);
navigate("/auth/login");
} catch (error) {
form.reset();
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to reset your password",
description: message,
});
}
}
};
// Redirect the user to the forgot password page if the token is not present.
useEffect(() => {
if (!token) navigate("/auth/forgot-password");
}, [token, navigate]);
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Reset your password</h2>
<h6 className="leading-relaxed opacity-75">
Enter a new password below, and make sure it's secure.
</h6>
</div>
<div>
<Form {...form}>
<form
ref={formRef}
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
temporarily.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading} className="mt-4 w-full">
Update Password
</Button>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,80 @@
import { ArrowRight, Info, SealCheck, Warning } from "@phosphor-icons/react";
import { Alert, AlertDescription, AlertTitle, Button } from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useEffect } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useToast } from "@/client/hooks/use-toast";
import { queryClient } from "@/client/libs/query-client";
import { useVerifyEmail } from "@/client/services/auth";
export const VerifyEmailPage = () => {
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const { verifyEmail, loading } = useVerifyEmail();
useEffect(() => {
const handleVerifyEmail = async (token: string) => {
try {
await verifyEmail({ token });
await queryClient.invalidateQueries({ queryKey: ["user"] });
toast({
variant: "success",
icon: <SealCheck size={16} weight="bold" />,
title: "Your email address has been verified successfully.",
});
navigate("/dashboard/resumes", { replace: true });
} catch (error) {
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to verify your email address",
description: message,
});
}
}
};
if (!token) return;
handleVerifyEmail(token);
}, [token, navigate, verifyEmail]);
return (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">Verify your email address</h2>
<p className="leading-relaxed opacity-75">
You should have received an email from <strong>Reactive Resume</strong> with a link to
verify your account.
</p>
</div>
<Alert variant="info">
<Info size={18} />
<AlertTitle>Please note that this step is completely optional.</AlertTitle>
<AlertDescription>
We verify your email address only to ensure that we can send you a password reset link in
case you forget your password.
</AlertDescription>
</Alert>
<Button asChild disabled={loading}>
<Link to="/dashboard">
Continue to Dashboard
<ArrowRight className="ml-2" />
</Link>
</Button>
</div>
);
};

View File

@ -0,0 +1,104 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRight, Warning } from "@phosphor-icons/react";
import { twoFactorSchema } from "@reactive-resume/dto";
import { usePasswordToggle } from "@reactive-resume/hooks";
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { z } from "zod";
import { toast } from "@/client/hooks/use-toast";
import { useVerifyOtp } from "@/client/services/auth";
type FormValues = z.infer<typeof twoFactorSchema>;
export const VerifyOtpPage = () => {
const navigate = useNavigate();
const { verifyOtp, loading } = useVerifyOtp();
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
const form = useForm<FormValues>({
resolver: zodResolver(twoFactorSchema),
defaultValues: { code: "" },
});
const onSubmit = async (data: FormValues) => {
try {
await verifyOtp(data);
navigate("/dashboard");
} catch (error) {
form.reset();
if (error instanceof AxiosError) {
const message = error.response?.data.message || error.message;
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while trying to sign in",
description: message,
});
}
}
};
return (
<div className="space-y-8">
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold tracking-tight">Two Step Verification</h2>
<h6>
<span className="opacity-75">
Enter the one-time password provided by your authenticator app below.
</span>
<Button asChild variant="link" className="px-1.5">
<Link to="/auth/backup-otp">
Lost your device? <ArrowRight className="ml-1" />
</Link>
</Button>
</h6>
</div>
<div>
<Form {...form}>
<form
ref={formRef}
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
name="code"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>One-Time Password</FormLabel>
<FormControl>
<Input placeholder="123456" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading} className="mt-4 w-full">
Sign in
</Button>
</form>
</Form>
</div>
</div>
);
};

View File

@ -0,0 +1,59 @@
import { HouseSimple, SidebarSimple } from "@phosphor-icons/react";
import { useBreakpoint } from "@reactive-resume/hooks";
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { Link } from "react-router-dom";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore } from "@/client/stores/resume";
export const BuilderHeader = () => {
const { isDesktop } = useBreakpoint();
const defaultPanelSize = isDesktop ? 25 : 0;
const toggle = useBuilderStore((state) => state.toggle);
const title = useResumeStore((state) => state.resume.title);
const isDragging = useBuilderStore(
(state) => state.panel.left.isDragging || state.panel.right.isDragging,
);
const leftPanelSize = useBuilderStore(
(state) => state.panel.left.ref?.getSize() ?? defaultPanelSize,
);
const rightPanelSize = useBuilderStore(
(state) => state.panel.right.ref?.getSize() ?? defaultPanelSize,
);
const onToggle = (side: "left" | "right") => toggle(side);
return (
<div
style={{ left: `${leftPanelSize}%`, right: `${rightPanelSize}%` }}
className={cn(
"fixed inset-x-0 top-0 z-[100] h-16 bg-secondary-accent/50 backdrop-blur-lg lg:z-20",
!isDragging && "transition-[left,right]",
)}
>
<div className="flex h-full items-center justify-between px-4">
<Button size="icon" variant="ghost" onClick={() => onToggle("left")}>
<SidebarSimple />
</Button>
<div className="flex items-center justify-center gap-x-1">
<Button asChild size="icon" variant="ghost">
<Link to="/dashboard/resumes">
<HouseSimple />
</Link>
</Button>
<span className="mr-2 text-xs opacity-40">{"/"}</span>
<h1 className="font-medium">{title}</h1>
</div>
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>
<SidebarSimple className="-scale-x-100" />
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,167 @@
import {
ArrowClockwise,
ArrowCounterClockwise,
CircleNotch,
ClockClockwise,
CubeFocus,
DownloadSimple,
Hash,
LineSegment,
LinkSimple,
MagnifyingGlassMinus,
MagnifyingGlassPlus,
} from "@phosphor-icons/react";
import { Button, Separator, Toggle, Tooltip } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import { usePrintResume } from "@/client/services/resume";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore, useTemporalResumeStore } from "@/client/stores/resume";
export const BuilderToolbar = () => {
const setValue = useResumeStore((state) => state.setValue);
const undo = useTemporalResumeStore((state) => state.undo);
const redo = useTemporalResumeStore((state) => state.redo);
const transformRef = useBuilderStore((state) => state.transform.ref);
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
const pageOptions = useResumeStore((state) => state.resume.data.metadata.page.options);
const { printResume, loading } = usePrintResume();
const onPrint = async () => {
const { url } = await printResume({ id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
return (
<motion.div
initial={{ opacity: 0, bottom: -64 }}
whileHover={{ opacity: 1, bottom: 0 }}
animate={{ opacity: 0.3, bottom: -28 }}
className="fixed inset-x-0 mx-auto pb-4 pt-6 text-center"
>
<div className="inline-flex items-center justify-center rounded-full bg-background px-4 shadow-xl">
{/* Undo */}
<Tooltip content="Undo">
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => undo()}>
<ArrowCounterClockwise />
</Button>
</Tooltip>
{/* Redo */}
<Tooltip content="Redo">
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => redo()}>
<ArrowClockwise />
</Button>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Zoom In */}
<Tooltip content="Zoom In">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomIn(0.2)}
>
<MagnifyingGlassPlus />
</Button>
</Tooltip>
{/* Zoom Out */}
<Tooltip content="Zoom Out">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomOut(0.2)}
>
<MagnifyingGlassMinus />
</Button>
</Tooltip>
<Tooltip content="Reset Zoom">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.resetTransform()}
>
<ClockClockwise />
</Button>
</Tooltip>
{/* Center Artboard */}
<Tooltip content="Center Artboard">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.centerView()}
>
<CubeFocus />
</Button>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Toggle Page Break Line */}
<Tooltip content="Toggle Page Break Line">
<Toggle
className="rounded-none"
pressed={pageOptions.breakLine}
onPressedChange={(pressed) => {
setValue("metadata.page.options.breakLine", pressed);
}}
>
<LineSegment />
</Toggle>
</Tooltip>
{/* Toggle Page Numbers */}
<Tooltip content="Toggle Page Numbers">
<Toggle
className="rounded-none"
pressed={pageOptions.pageNumbers}
onPressedChange={(pressed) => {
setValue("metadata.page.options.pageNumbers", pressed);
}}
>
<Hash />
</Toggle>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Copy Link to Resume */}
<Tooltip content="Copy Link to Resume">
<Button size="icon" variant="ghost" className="rounded-none" disabled={!isPublic}>
<LinkSimple />
</Button>
</Tooltip>
{/* Download PDF */}
<Tooltip content="Download PDF">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={onPrint}
disabled={loading}
>
{loading ? <CircleNotch className="animate-spin" /> : <DownloadSimple />}
</Button>
</Tooltip>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,100 @@
import { useBreakpoint } from "@reactive-resume/hooks";
import { Panel, PanelGroup, PanelResizeHandle, Sheet, SheetContent } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { Outlet } from "react-router-dom";
import { useBuilderStore } from "@/client/stores/builder";
import { BuilderHeader } from "./_components/header";
import { BuilderToolbar } from "./_components/toolbar";
import { LeftSidebar } from "./sidebars/left";
import { RightSidebar } from "./sidebars/right";
const OutletSlot = () => (
<>
<BuilderHeader />
<div className="absolute inset-0">
<Outlet />
</div>
<BuilderToolbar />
</>
);
export const BuilderLayout = () => {
const { isDesktop } = useBreakpoint();
const panel = useBuilderStore((state) => state.panel);
const sheet = useBuilderStore((state) => state.sheet);
const onOpenAutoFocus = (event: Event) => event.preventDefault();
if (isDesktop) {
return (
<div className="relative h-full w-full overflow-hidden">
<PanelGroup direction="horizontal">
<Panel
collapsible
minSize={20}
maxSize={35}
defaultSize={28}
ref={panel.left.setRef}
className={cn("z-10 bg-background", !panel.left.isDragging && "transition-[flex]")}
>
<LeftSidebar />
</Panel>
<PanelResizeHandle
isDragging={panel.left.isDragging}
onDragging={panel.left.setDragging}
/>
<Panel>
<OutletSlot />
</Panel>
<PanelResizeHandle
isDragging={panel.right.isDragging}
onDragging={panel.right.setDragging}
/>
<Panel
collapsible
minSize={20}
maxSize={35}
defaultSize={28}
ref={panel.right.setRef}
className={cn("z-10 bg-background", !panel.right.isDragging && "transition-[flex]")}
>
<RightSidebar />
</Panel>
</PanelGroup>
</div>
);
}
return (
<div className="relative">
<Sheet open={sheet.left.open} onOpenChange={sheet.left.setOpen}>
<SheetContent
side="left"
className="p-0 sm:max-w-xl"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
>
<LeftSidebar />
</SheetContent>
</Sheet>
<OutletSlot />
<Sheet open={sheet.right.open} onOpenChange={sheet.right.setOpen}>
<SheetContent
side="right"
className="p-0 sm:max-w-xl"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
>
<RightSidebar />
</SheetContent>
</Sheet>
</div>
);
};

View File

@ -0,0 +1,98 @@
import { ResumeDto } from "@reactive-resume/dto";
import { SectionKey } from "@reactive-resume/schema";
import {
Artboard,
PageBreakLine,
PageGrid,
PageNumber,
PageWrapper,
Rhyhorn,
} from "@reactive-resume/templates";
import { pageSizeMap } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import { Helmet } from "react-helmet-async";
import { LoaderFunction, redirect } from "react-router-dom";
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { queryClient } from "@/client/libs/query-client";
import { findResumeById } from "@/client/services/resume";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore } from "@/client/stores/resume";
export const BuilderPage = () => {
const title = useResumeStore((state) => state.resume.title);
const resume = useResumeStore((state) => state.resume.data);
const setTransformRef = useBuilderStore((state) => state.transform.setRef);
const { pageHeight, showBreakLine, showPageNumbers } = useMemo(() => {
const { format, options } = resume.metadata.page;
return {
pageHeight: pageSizeMap[format].height,
showBreakLine: options.breakLine,
showPageNumbers: options.pageNumbers,
};
}, [resume.metadata.page]);
return (
<>
<Helmet>
<title>{title} - Reactive Resume</title>
</Helmet>
<TransformWrapper
centerOnInit
minScale={0.4}
initialScale={1}
limitToBounds={false}
velocityAnimation={{ disabled: true }}
ref={(ref: ReactZoomPanPinchRef) => setTransformRef(ref)}
>
<TransformComponent wrapperClass="!w-screen !h-screen">
<PageGrid $offset={32}>
<AnimatePresence presenceAffectsLayout>
{resume.metadata.layout.map((columns, pageIndex) => (
<motion.div
layout
key={pageIndex}
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
>
<Artboard resume={resume}>
<PageWrapper>
{showPageNumbers && <PageNumber>Page {pageIndex + 1}</PageNumber>}
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
{showBreakLine && <PageBreakLine $pageHeight={pageHeight} />}
</PageWrapper>
</Artboard>
</motion.div>
))}
</AnimatePresence>
</PageGrid>
</TransformComponent>
</TransformWrapper>
</>
);
};
export const builderLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
try {
const id = params.id as string;
const resume = await queryClient.fetchQuery({
queryKey: ["resume", { id }],
queryFn: () => findResumeById({ id }),
});
useResumeStore.setState({ resume });
useResumeStore.temporal.getState().clear();
return resume;
} catch (error) {
return redirect("/dashboard");
}
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { awardSchema, defaultAward } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = awardSchema;
type FormValues = z.infer<typeof formSchema>;
export const AwardsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultAward,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="awards" form={form} defaultValues={defaultAward}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} placeholder="3rd Runner Up" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="awarder"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Awarder</FormLabel>
<FormControl>
<Input {...field} placeholder="TechCrunch Disrupt SF" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://techcrunch.com/events/disrupt-sf-2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { certificationSchema, defaultCertification } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = certificationSchema;
type FormValues = z.infer<typeof formSchema>;
export const CertificationsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultCertification,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="certifications" form={form} defaultValues={defaultCertification}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Web Developer Bootcamp" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="issuer"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Issuer</FormLabel>
<FormControl>
<Input {...field} placeholder="Udemy" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://udemy.com/certificate/UC-..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,195 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { CustomSection, customSectionSchema, defaultCustomSection } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
Slider,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { DialogName, useDialog } from "@/client/stores/dialog";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
export const CustomSectionDialog = () => {
const { payload } = useDialog<CustomSection>("custom");
const form = useForm<FormValues>({
defaultValues: defaultCustomSection,
resolver: zodResolver(formSchema),
});
if (!payload) return null;
return (
<SectionDialog<FormValues>
form={form}
id={payload.id as DialogName}
defaultValues={defaultCustomSection}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="level"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Level</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={0}
max={5}
value={[field.value]}
orientation="horizontal"
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{field.value}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultEducation, educationSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = educationSchema;
type FormValues = z.infer<typeof formSchema>;
export const EducationDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultEducation,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="education" form={form} defaultValues={defaultEducation}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="institution"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Institution</FormLabel>
<FormControl>
<Input {...field} placeholder="Carnegie Mellon University" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="studyType"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Type of Study</FormLabel>
<FormControl>
<Input {...field} placeholder="Bachelor's Degree" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="area"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Area of Study</FormLabel>
<FormControl>
<Input {...field} placeholder="Computer Science" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="score"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Score</FormLabel>
<FormControl>
<Input {...field} placeholder="9.2 GPA" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2006 - Oct 2012" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.cmu.edu/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,126 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultExperience, experienceSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = experienceSchema;
type FormValues = z.infer<typeof formSchema>;
export const ExperienceDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultExperience,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="experience" form={form} defaultValues={defaultExperience}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="company"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Company</FormLabel>
<FormControl>
<Input {...field} placeholder="Alphabet Inc." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="position"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Position</FormLabel>
<FormControl>
<Input {...field} placeholder="Chief Executive Officer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Dec 2019 - Present" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="location"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Location</FormLabel>
<FormControl>
<Input {...field} placeholder="New York, NY" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.abc.xyz/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,93 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultInterest, interestSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = interestSchema;
type FormValues = z.infer<typeof formSchema>;
export const InterestsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultInterest,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="interests" form={form} defaultValues={defaultInterest}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Video Games" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,85 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultLanguage, languageSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Slider,
} from "@reactive-resume/ui";
import { getCEFRLevel } from "@reactive-resume/utils";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = languageSchema;
type FormValues = z.infer<typeof formSchema>;
export const LanguagesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultLanguage,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="languages" form={form} defaultValues={defaultLanguage}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="German" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="fluency"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Fluency</FormLabel>
<FormControl>
<Input {...field} placeholder="Native Speaker" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="fluencyLevel"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Fluency (CEFR)</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={1}
max={6}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{getCEFRLevel(field.value)}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultProfile, profileSchema } from "@reactive-resume/schema";
import {
Avatar,
AvatarImage,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = profileSchema;
type FormValues = z.infer<typeof formSchema>;
export const ProfilesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultProfile,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="profiles" form={form} defaultValues={defaultProfile}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="network"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Network</FormLabel>
<FormControl>
<Input {...field} placeholder="LinkedIn" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="username"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="johndoe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>URL</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://linkedin.com/in/johndoe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="icon"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel htmlFor="iconSlug">Icon</FormLabel>
<FormControl>
<div className="flex items-center gap-x-2">
<Avatar className="h-8 w-8 bg-white">
{field.value && (
<AvatarImage
className="p-1.5"
src={`https://cdn.simpleicons.org/${field.value}`}
/>
)}
</Avatar>
<Input {...field} id="iconSlug" placeholder="linkedin" />
</div>
</FormControl>
<FormMessage />
<FormDescription className="ml-10">
Powered by{" "}
<a
href="https://simpleicons.org/"
target="_blank"
rel="noopener noreferrer nofollow"
className="font-medium"
>
Simple Icons
</a>
</FormDescription>
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,160 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultProject, projectSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = projectSchema;
type FormValues = z.infer<typeof formSchema>;
export const ProjectsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultProject,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="projects" form={form} defaultValues={defaultProject}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Reactive Resume" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Open Source Resume Builder" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Sep 2018 - Present" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://rxresu.me" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultPublication, publicationSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = publicationSchema;
type FormValues = z.infer<typeof formSchema>;
export const PublicationsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultPublication,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="publications" form={form} defaultValues={defaultPublication}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The Great Gatsby" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="publisher"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Publisher</FormLabel>
<FormControl>
<Input {...field} placeholder="Charles Scribner's Sons" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Release Date</FormLabel>
<FormControl>
<Input {...field} placeholder="April 10, 1925" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://books.google.com/..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,98 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultReference, referenceSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = referenceSchema;
type FormValues = z.infer<typeof formSchema>;
export const ReferencesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultReference,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="references" form={form} defaultValues={defaultReference}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Cosmo Kramer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Neighbour" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://linkedin.com/in/cosmo.kramer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,133 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultSkill, skillSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Slider,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = skillSchema;
type FormValues = z.infer<typeof formSchema>;
export const SkillsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultSkill,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="skills" form={form} defaultValues={defaultSkill}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Content Management" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Advanced" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="level"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Level</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={1}
max={5}
value={[field.value]}
orientation="horizontal"
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{field.value}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="WordPress, Joomla, Webflow etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,126 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultVolunteer, volunteerSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = volunteerSchema;
type FormValues = z.infer<typeof formSchema>;
export const VolunteerDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultVolunteer,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="volunteer" form={form} defaultValues={defaultVolunteer}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="organization"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Organization</FormLabel>
<FormControl>
<Input {...field} placeholder="Amnesty International" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="position"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Position</FormLabel>
<FormControl>
<Input {...field} placeholder="Recruiter" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Dec 2016 - Aug 2017" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="location"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Location</FormLabel>
<FormControl>
<Input {...field} placeholder="New York, NY" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.amnesty.org/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,193 @@
import { Plus, PlusCircle } from "@phosphor-icons/react";
import {
Award,
Certification,
CustomSectionItem,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
Skill,
Volunteer,
} from "@reactive-resume/schema";
import { Button, ScrollArea, Separator } from "@reactive-resume/ui";
import { getCEFRLevel } from "@reactive-resume/utils";
import { Fragment, useRef } from "react";
import { Link } from "react-router-dom";
import { Icon } from "@/client/components/icon";
import { UserAvatar } from "@/client/components/user-avatar";
import { UserOptions } from "@/client/components/user-options";
import { useResumeStore } from "@/client/stores/resume";
import { BasicsSection } from "./sections/basics";
import { SectionBase } from "./sections/shared/section-base";
import { SectionIcon } from "./sections/shared/section-icon";
import { SummarySection } from "./sections/summary";
export const LeftSidebar = () => {
const containterRef = useRef<HTMLDivElement | null>(null);
const addSection = useResumeStore((state) => state.addSection);
const customSections = useResumeStore((state) => state.resume.data.sections.custom);
const scrollIntoView = (selector: string) => {
const section = containterRef.current?.querySelector(selector);
section?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
<Button asChild size="icon" variant="ghost" className="h-8 w-8 rounded-full">
<Link to="/dashboard">
<Icon size={14} />
</Link>
</Button>
<div className="flex flex-col items-center justify-center gap-y-2">
<SectionIcon id="basics" name="Basics" onClick={() => scrollIntoView("#basics")} />
<SectionIcon id="summary" onClick={() => scrollIntoView("#summary")} />
<SectionIcon id="profiles" onClick={() => scrollIntoView("#profiles")} />
<SectionIcon id="experience" onClick={() => scrollIntoView("#experience")} />
<SectionIcon id="education" onClick={() => scrollIntoView("#education")} />
<SectionIcon id="awards" onClick={() => scrollIntoView("#awards")} />
<SectionIcon id="certifications" onClick={() => scrollIntoView("#certifications")} />
<SectionIcon id="interests" onClick={() => scrollIntoView("#interests")} />
<SectionIcon id="languages" onClick={() => scrollIntoView("#languages")} />
<SectionIcon id="volunteer" onClick={() => scrollIntoView("#volunteer")} />
<SectionIcon id="projects" onClick={() => scrollIntoView("#projects")} />
<SectionIcon id="publications" onClick={() => scrollIntoView("#publications")} />
<SectionIcon id="skills" onClick={() => scrollIntoView("#skills")} />
<SectionIcon id="references" onClick={() => scrollIntoView("#references")} />
<SectionIcon
id="custom"
variant="outline"
name="Add a new section"
icon={<Plus size={14} />}
onClick={() => {
addSection();
scrollIntoView("& > section:last-of-type");
}}
/>
</div>
<UserOptions>
<Button size="icon" variant="ghost" className="rounded-full">
<UserAvatar size={28} />
</Button>
</UserOptions>
</div>
<ScrollArea orientation="vertical" className="h-screen flex-1">
<div ref={containterRef} className="grid gap-y-6 p-6 @container/left">
<BasicsSection />
<Separator />
<SummarySection />
<Separator />
<SectionBase<Profile>
id="profiles"
title={(item) => item.network}
description={(item) => item.username}
/>
<Separator />
<SectionBase<Experience>
id="experience"
title={(item) => item.company}
description={(item) => item.position}
/>
<Separator />
<SectionBase<Education>
id="education"
title={(item) => item.institution}
description={(item) => item.area}
/>
<Separator />
<SectionBase<Award>
id="awards"
title={(item) => item.title}
description={(item) => item.awarder}
/>
<Separator />
<SectionBase<Certification>
id="certifications"
title={(item) => item.name}
description={(item) => item.issuer}
/>
<Separator />
<SectionBase<Interest>
id="interests"
title={(item) => item.name}
description={(item) => {
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
}}
/>
<Separator />
<SectionBase<Language>
id="languages"
title={(item) => item.name}
description={(item) => item.fluency || getCEFRLevel(item.fluencyLevel)}
/>
<Separator />
<SectionBase<Volunteer>
id="volunteer"
title={(item) => item.organization}
description={(item) => item.position}
/>
<Separator />
<SectionBase<Project>
id="projects"
title={(item) => item.name}
description={(item) => item.description}
/>
<Separator />
<SectionBase<Publication>
id="publications"
title={(item) => item.name}
description={(item) => item.publisher}
/>
<Separator />
<SectionBase<Skill>
id="skills"
title={(item) => item.name}
description={(item) => {
if (item.description) return item.description;
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
}}
/>
<Separator />
<SectionBase<Reference>
id="references"
title={(item) => item.name}
description={(item) => item.description}
/>
{/* Custom Sections */}
{Object.values(customSections).map((section) => (
<Fragment key={section.id}>
<Separator />
<SectionBase<CustomSectionItem>
id={`custom.${section.id}`}
title={(item) => item.name}
description={(item) => item.description}
/>
</Fragment>
))}
<Separator />
<Button size="lg" variant="outline" onClick={addSection}>
<PlusCircle />
<span className="ml-2">Add a new section</span>
</Button>
</div>
</ScrollArea>
</div>
);
};

View File

@ -0,0 +1,97 @@
import { basicsSchema } from "@reactive-resume/schema";
import { Input, Label } from "@reactive-resume/ui";
import { useResumeStore } from "@/client/stores/resume";
import { CustomFieldsSection } from "./custom/section";
import { PictureSection } from "./picture/section";
import { getSectionIcon } from "./shared/section-icon";
import { URLInput } from "./shared/url-input";
export const BasicsSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<section id="basics" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("basics")}
<h2 className="line-clamp-1 text-3xl font-bold">Basics</h2>
</div>
</header>
<main className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<PictureSection />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="basics.name">Full Name</Label>
<Input
id="basics.name"
placeholder="John Doe"
value={basics.name}
hasError={!basicsSchema.pick({ name: true }).safeParse({ name: basics.name }).success}
onChange={(event) => setValue("basics.name", event.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="basics.headline">Headline</Label>
<Input
id="basics.headline"
placeholder="Highly Creative Frontend Web Developer"
value={basics.headline}
onChange={(event) => setValue("basics.headline", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.email">Email Address</Label>
<Input
id="basics.email"
placeholder="john.doe@example.com"
value={basics.email}
hasError={
!basicsSchema.pick({ email: true }).safeParse({ email: basics.email }).success
}
onChange={(event) => setValue("basics.email", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.url">Website</Label>
<URLInput
id="basics.url"
value={basics.url}
placeholder="https://example.com"
onChange={(value) => setValue("basics.url", value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.phone">Phone Number</Label>
<Input
id="basics.phone"
placeholder="+1 (123) 4567 7890"
value={basics.phone}
onChange={(event) => setValue("basics.phone", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.location">Location</Label>
<Input
id="basics.location"
placeholder="105 Cedarhurst Ave, Cedarhurst, NY 11516"
value={basics.location}
onChange={(event) => setValue("basics.location", event.target.value)}
/>
</div>
<CustomFieldsSection className="col-span-2" />
</main>
</section>
);
};

View File

@ -0,0 +1,124 @@
import { createId } from "@paralleldrive/cuid2";
import { DotsSixVertical, Plus, X } from "@phosphor-icons/react";
import { CustomField as ICustomField } from "@reactive-resume/schema";
import { Button, Input } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, Reorder, useDragControls } from "framer-motion";
import { useResumeStore } from "@/client/stores/resume";
type CustomFieldProps = {
field: ICustomField;
onChange: (field: ICustomField) => void;
onRemove: (id: string) => void;
};
export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) => {
const controls = useDragControls();
const handleChange = (key: "name" | "value", value: string) =>
onChange({ ...field, [key]: value });
return (
<Reorder.Item
value={field}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
>
<div className="flex items-end justify-between space-x-4">
<Button
size="icon"
variant="ghost"
className="shrink-0"
onPointerDown={(event) => controls.start(event)}
>
<DotsSixVertical />
</Button>
<Input
placeholder="Name"
className="!ml-2"
value={field.name}
onChange={(event) => handleChange("name", event.target.value)}
/>
<Input
placeholder="Value"
value={field.value}
onChange={(event) => handleChange("value", event.target.value)}
/>
<Button
size="icon"
variant="ghost"
className="!ml-2 shrink-0"
onClick={() => onRemove(field.id)}
>
<X />
</Button>
</div>
</Reorder.Item>
);
};
type Props = {
className?: string;
};
export const CustomFieldsSection = ({ className }: Props) => {
const setValue = useResumeStore((state) => state.setValue);
const customFields = useResumeStore((state) => state.resume.data.basics.customFields);
const onAddCustomField = () => {
setValue("basics.customFields", [...customFields, { id: createId(), name: "", value: "" }]);
};
const onChangeCustomField = (field: ICustomField) => {
const index = customFields.findIndex((item) => item.id === field.id);
const newCustomFields = JSON.parse(JSON.stringify(customFields)) as ICustomField[];
newCustomFields[index] = field;
setValue("basics.customFields", newCustomFields);
};
const onReorderCustomFields = (values: ICustomField[]) => {
setValue("basics.customFields", values);
};
const onRemoveCustomField = (id: string) => {
setValue(
"basics.customFields",
customFields.filter((field) => field.id !== id),
);
};
return (
<div className={cn("space-y-4", className)}>
<AnimatePresence>
<Reorder.Group
axis="y"
className="space-y-4"
values={customFields}
onReorder={onReorderCustomFields}
>
{customFields.map((field) => (
<CustomField
field={field}
key={field.id}
onChange={onChangeCustomField}
onRemove={onRemoveCustomField}
/>
))}
</Reorder.Group>
</AnimatePresence>
<Button variant="link" onClick={onAddCustomField}>
<Plus className="mr-2" />
<span>Add a custom field</span>
</Button>
</div>
);
};

View File

@ -0,0 +1,219 @@
import {
AspectRatio,
Checkbox,
Input,
Label,
ToggleGroup,
ToggleGroupItem,
Tooltip,
} from "@reactive-resume/ui";
import { useMemo } from "react";
import { useResumeStore } from "@/client/stores/resume";
// Aspect Ratio Helpers
const stringToRatioMap = {
square: 1,
portrait: 0.75,
horizontal: 1.33,
} as const;
const ratioToStringMap = {
"1": "square",
"0.75": "portrait",
"1.33": "horizontal",
} as const;
type AspectRatio = keyof typeof stringToRatioMap;
// Border Radius Helpers
const stringToBorderRadiusMap = {
square: 0,
rounded: 6,
circle: 9999,
};
const borderRadiusToStringMap = {
"0": "square",
"6": "rounded",
"9999": "circle",
};
type BorderRadius = keyof typeof stringToBorderRadiusMap;
export const PictureOptions = () => {
const setValue = useResumeStore((state) => state.setValue);
const picture = useResumeStore((state) => state.resume.data.basics.picture);
const aspectRatio = useMemo(() => {
const ratio = picture.aspectRatio?.toString() as keyof typeof ratioToStringMap;
return ratioToStringMap[ratio];
}, [picture.aspectRatio]);
const onAspectRatioChange = (value: AspectRatio) => {
if (!value) return;
setValue("basics.picture.aspectRatio", stringToRatioMap[value]);
};
const borderRadius = useMemo(() => {
const radius = picture.borderRadius?.toString() as keyof typeof borderRadiusToStringMap;
return borderRadiusToStringMap[radius];
}, [picture.borderRadius]);
const onBorderRadiusChange = (value: BorderRadius) => {
if (!value) return;
setValue("basics.picture.borderRadius", stringToBorderRadiusMap[value]);
};
return (
<div className="flex flex-col gap-y-5">
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.size" className="col-span-1">
Size (in px)
</Label>
<Input
type="number"
id="picture.size"
placeholder="128"
value={picture.size}
className="col-span-2"
onChange={(event) => {
setValue("basics.picture.size", event.target.valueAsNumber);
}}
/>
</div>
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.aspectRatio" className="col-span-1">
Aspect Ratio
</Label>
<div className="col-span-2 flex items-center justify-between">
<ToggleGroup
type="single"
value={aspectRatio}
onValueChange={onAspectRatioChange}
className="flex items-center justify-center"
>
<Tooltip content="Square">
<ToggleGroupItem value="square">
<div className="h-3 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Horizontal">
<ToggleGroupItem value="horizontal">
<div className="h-2 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Portrait">
<ToggleGroupItem value="portrait">
<div className="h-3 w-2 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
</ToggleGroup>
<Input
min={0.1}
max={2}
step={0.05}
type="number"
className="w-[60px]"
id="picture.aspectRatio"
value={picture.aspectRatio}
onChange={(event) => {
setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0);
}}
/>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.borderRadius" className="col-span-1">
Border Radius
</Label>
<div className="col-span-2 flex items-center justify-between">
<ToggleGroup
type="single"
value={borderRadius}
onValueChange={onBorderRadiusChange}
className="flex items-center justify-center"
>
<Tooltip content="Square">
<ToggleGroupItem value="square">
<div className="h-3 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Rounded">
<ToggleGroupItem value="rounded">
<div className="h-3 w-3 rounded-sm border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Circle">
<ToggleGroupItem value="circle">
<div className="h-3 w-3 rounded-full border border-foreground" />
</ToggleGroupItem>
</Tooltip>
</ToggleGroup>
<Input
min={0}
step={2}
max={9999}
type="number"
className="w-[60px]"
id="picture.borderRadius"
value={picture.borderRadius}
onChange={(event) => {
setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0);
}}
/>
</div>
</div>
<div>
<div className="grid grid-cols-3 items-start gap-x-6">
<div className="col-span-1">
<Label>Effects</Label>
</div>
<div className="col-span-2 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.hidden"
checked={picture.effects.hidden}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.hidden", checked);
}}
/>
<Label htmlFor="picture.effects.hidden">Hidden</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.border"
checked={picture.effects.border}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.border", checked);
}}
/>
<Label htmlFor="picture.effects.border">Border</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.grayscale"
checked={picture.effects.grayscale}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.grayscale", checked);
}}
/>
<Label htmlFor="picture.effects.grayscale">Grayscale</Label>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,102 @@
import { Aperture, UploadSimple } from "@phosphor-icons/react";
import {
Avatar,
AvatarFallback,
AvatarImage,
buttonVariants,
Input,
Label,
Popover,
PopoverContent,
PopoverTrigger,
} from "@reactive-resume/ui";
import { cn, getInitials } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo, useRef } from "react";
import { z } from "zod";
import { useUploadImage } from "@/client/services/storage";
import { useResumeStore } from "@/client/stores/resume";
import { PictureOptions } from "./options";
export const PictureSection = () => {
const inputRef = useRef<HTMLInputElement>(null);
const { uploadImage, loading } = useUploadImage();
const setValue = useResumeStore((state) => state.setValue);
const name = useResumeStore((state) => state.resume.data.basics.name);
const picture = useResumeStore((state) => state.resume.data.basics.picture);
const isValidUrl = useMemo(() => z.string().url().safeParse(picture.url).success, [picture.url]);
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
const response = await uploadImage(file);
const url = response.data;
setValue("basics.picture.url", url);
}
};
return (
<div className="flex items-center gap-x-4">
<Avatar className="h-14 w-14">
{isValidUrl && <AvatarImage src={picture.url} />}
<AvatarFallback className="text-lg font-bold">{getInitials(name)}</AvatarFallback>
</Avatar>
<div className="flex w-full flex-col gap-y-1.5">
<Label htmlFor="basics.picture.url">Picture</Label>
<div className="flex items-center gap-x-2">
<Input
id="basics.picture.url"
placeholder="https://..."
value={picture.url}
onChange={(event) => setValue("basics.picture.url", event.target.value)}
/>
<AnimatePresence>
{/* Show options button if picture exists */}
{isValidUrl && (
<Popover>
<PopoverTrigger asChild>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
>
<Aperture />
</motion.button>
</PopoverTrigger>
<PopoverContent className="w-[360px]">
<PictureOptions />
</PopoverContent>
</Popover>
)}
{/* Show upload button if picture doesn't exist, else show remove button to delete picture */}
{!isValidUrl && (
<>
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
<motion.button
disabled={loading}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => inputRef.current?.click()}
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
>
<UploadSimple />
</motion.button>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,146 @@
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Plus } from "@phosphor-icons/react";
import { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import get from "lodash.get";
import { useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "./section-icon";
import { SectionListItem } from "./section-list-item";
import { SectionOptions } from "./section-options";
type Props<T extends SectionItem> = {
id: SectionKey;
title: (item: T) => string;
description?: (item: T) => string | undefined;
};
export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => {
const { open } = useDialog(id);
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore((state) =>
get(state.resume.data.sections, id),
) as SectionWithItem<T>;
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
if (!section) return null;
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (active.id !== over.id) {
const oldIndex = section.items.findIndex((item) => item.id === active.id);
const newIndex = section.items.findIndex((item) => item.id === over.id);
const sortedList = arrayMove(section.items as T[], oldIndex, newIndex);
setValue(`sections.${id}.items`, sortedList);
}
};
const onCreate = () => open("create", { id });
const onUpdate = (item: T) => open("update", { id, item });
const onDuplicate = (item: T) => open("duplicate", { id, item });
const onDelete = (item: T) => open("delete", { id, item });
const onToggleVisibility = (index: number) => {
const visible = get(section, `items[${index}].visible`, true);
setValue(`sections.${id}.items[${index}].visible`, !visible);
};
return (
<motion.section
id={id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid gap-y-6"
>
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon(id)}
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
</div>
<div className="flex items-center gap-x-2">
<SectionOptions id={id} />
</div>
</header>
<main className={cn("grid transition-opacity", !section.visible && "opacity-50")}>
{section.items.length === 0 && (
<Button
variant="outline"
onClick={onCreate}
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
>
<Plus size={14} />
<span className="font-medium">Add New {section.name}</span>
</Button>
)}
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
collisionDetection={closestCenter}
modifiers={[restrictToParentElement]}
>
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
<AnimatePresence>
{section.items.map((item, index) => (
<SectionListItem
id={item.id}
key={item.id}
visible={item.visible}
title={title(item as T)}
description={description?.(item as T)}
onUpdate={() => onUpdate(item as T)}
onDelete={() => onDelete(item as T)}
onDuplicate={() => onDuplicate(item as T)}
onToggleVisibility={() => onToggleVisibility(index)}
/>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</main>
{section.items.length > 0 && (
<footer className="flex items-center justify-end">
<Button variant="outline" className="ml-auto gap-x-2" onClick={onCreate}>
<Plus />
<span>Add New {section.name}</span>
</Button>
</footer>
)}
</motion.section>
);
};

View File

@ -0,0 +1,169 @@
import { createId } from "@paralleldrive/cuid2";
import { CopySimple, PencilSimple, Plus } from "@phosphor-icons/react";
import { SectionItem, SectionWithItem } from "@reactive-resume/schema";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
} from "@reactive-resume/ui";
import { produce } from "immer";
import get from "lodash.get";
import { useEffect, useMemo } from "react";
import { UseFormReturn } from "react-hook-form";
import { DialogName, useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
type Props<T extends SectionItem> = {
id: DialogName;
form: UseFormReturn<T>;
defaultValues: T;
children: React.ReactNode;
};
export const SectionDialog = <T extends SectionItem>({
id,
form,
defaultValues,
children,
}: Props<T>) => {
const { isOpen, mode, close, payload } = useDialog<T>(id);
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore((state) => {
if (!id) return null;
return get(state.resume.data.sections, id);
}) as SectionWithItem<T> | null;
const name = useMemo(() => section?.name ?? "", [section?.name]);
const isCreate = mode === "create";
const isUpdate = mode === "update";
const isDelete = mode === "delete";
const isDuplicate = mode === "duplicate";
useEffect(() => {
if (isOpen) onReset();
}, [isOpen, payload]);
const onSubmit = async (values: T) => {
if (!section) return;
if (isCreate || isDuplicate) {
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
draft.push({ ...values, id: createId() });
}),
);
}
if (isUpdate) {
if (!payload.item?.id) return;
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
const index = draft.findIndex((item) => item.id === payload.item?.id);
if (index === -1) return;
draft[index] = values;
}),
);
}
if (isDelete) {
if (!payload.item?.id) return;
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
const index = draft.findIndex((item) => item.id === payload.item?.id);
if (index === -1) return;
draft.splice(index, 1);
}),
);
}
close();
};
const onReset = () => {
if (isCreate) form.reset({ ...defaultValues, id: createId() } as T);
if (isUpdate) form.reset({ ...defaultValues, ...payload.item });
if (isDuplicate) form.reset({ ...payload.item, id: createId() } as T);
if (isDelete) form.reset({ ...defaultValues, ...payload.item });
};
if (isDelete) {
return (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<Form {...form}>
<form>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this {name}?</AlertDialogTitle>
<AlertDialogDescription>
This action can be reverted by clicking on the undo button in the floating
toolbar.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
{isCreate && <Plus />}
{isUpdate && <PencilSimple />}
{isDuplicate && <CopySimple />}
<h2>
{isCreate && `Create a new ${name}`}
{isUpdate && `Update an existing ${name}`}
{isDuplicate && `Duplicate an existing ${name}`}
</h2>
</div>
</DialogTitle>
</DialogHeader>
{children}
<DialogFooter>
<Button type="submit">
{isCreate && "Create"}
{isUpdate && "Save Changes"}
{isDuplicate && "Duplicate"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,79 @@
import {
Article,
Books,
Briefcase,
Certificate,
CompassTool,
GameController,
GraduationCap,
HandHeart,
IconProps,
Medal,
PuzzlePiece,
ShareNetwork,
Translate,
User,
Users,
} from "@phosphor-icons/react";
import { defaultSection, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
import get from "lodash.get";
import { useResumeStore } from "@/client/stores/resume";
export const getSectionIcon = (id: SectionKey, props: IconProps = {}) => {
switch (id) {
// Left Sidebar
case "basics":
return <User size={18} {...props} />;
case "summary":
return <Article size={18} {...props} />;
case "awards":
return <Medal size={18} {...props} />;
case "profiles":
return <ShareNetwork size={18} {...props} />;
case "experience":
return <Briefcase size={18} {...props} />;
case "education":
return <GraduationCap size={18} {...props} />;
case "certifications":
return <Certificate size={18} {...props} />;
case "interests":
return <GameController size={18} {...props} />;
case "languages":
return <Translate size={18} {...props} />;
case "volunteer":
return <HandHeart size={18} {...props} />;
case "projects":
return <PuzzlePiece size={18} {...props} />;
case "publications":
return <Books size={18} {...props} />;
case "skills":
return <CompassTool size={18} {...props} />;
case "references":
return <Users size={18} {...props} />;
default:
return null;
}
};
type SectionIconProps = ButtonProps & {
id: SectionKey;
name?: string;
icon?: React.ReactNode;
};
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => {
const section = useResumeStore((state) =>
get(state.resume.data.sections, id, defaultSection),
) as SectionWithItem;
return (
<Tooltip side="right" content={name ?? section.name}>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
{icon ?? getSectionIcon(id, { size: 14 })}
</Button>
</Tooltip>
);
};

View File

@ -0,0 +1,103 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CopySimple, DotsSixVertical, PencilSimple, TrashSimple } from "@phosphor-icons/react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { motion } from "framer-motion";
export type SectionListItemProps = {
id: string;
title: string;
visible?: boolean;
description?: string;
// Callbacks
onUpdate?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
onToggleVisibility?: () => void;
};
export const SectionListItem = ({
id,
title,
description,
visible = true,
onUpdate,
onDuplicate,
onDelete,
onToggleVisibility,
}: SectionListItemProps) => {
const { setNodeRef, transform, transition, attributes, listeners, isDragging } = useSortable({
id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : undefined,
zIndex: isDragging ? 100 : undefined,
transition,
};
return (
<motion.section
ref={setNodeRef}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -50 }}
className="border-x border-t bg-secondary/10 first-of-type:rounded-t last-of-type:rounded-b last-of-type:border-b"
>
<div style={style} className="flex transition-opacity">
{/* Drag Handle */}
<div
{...listeners}
{...attributes}
className={cn(
"flex w-5 cursor-move items-center justify-center",
!isDragging && "hover:bg-secondary",
)}
>
<DotsSixVertical size={12} />
</div>
{/* List Item */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
"flex-1 cursor-context-menu p-4 hover:bg-secondary-accent",
!visible && "opacity-50",
)}
>
<h4 className="font-medium leading-relaxed">{title}</h4>
{description && <p className="text-xs leading-relaxed opacity-50">{description}</p>}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" side="left" sideOffset={-16}>
<DropdownMenuCheckboxItem checked={visible} onCheckedChange={onToggleVisibility}>
<span className="-ml-0.5">Visible</span>
</DropdownMenuCheckboxItem>
<DropdownMenuItem onClick={onUpdate}>
<PencilSimple size={14} />
<span className="ml-2">Edit</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<CopySimple size={14} />
<span className="ml-2">Copy</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-error" onClick={onDelete}>
<TrashSimple size={14} />
<span className="ml-2">Remove</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</motion.section>
);
};

View File

@ -0,0 +1,132 @@
import {
ArrowCounterClockwise,
Broom,
Columns,
DotsThreeVertical,
Eye,
EyeSlash,
PencilSimple,
Plus,
TrashSimple,
} from "@phosphor-icons/react";
import { defaultSections, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Input,
} from "@reactive-resume/ui";
import get from "lodash.get";
import { useMemo } from "react";
import { useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
type Props = { id: SectionKey };
export const SectionOptions = ({ id }: Props) => {
const { open } = useDialog(id);
const setValue = useResumeStore((state) => state.setValue);
const removeSection = useResumeStore((state) => state.removeSection);
const originalName = get(defaultSections, `${id}.name`, "") as SectionWithItem;
const section = useResumeStore((state) => get(state.resume.data.sections, id)) as SectionWithItem;
const hasItems = useMemo(() => "items" in section, [section]);
const isCustomSection = useMemo(() => id.startsWith("custom"), [id]);
const onCreate = () => open("create", { id });
const toggleVisibility = () => setValue(`sections.${id}.visible`, !section.visible);
const onResetName = () => setValue(`sections.${id}.name`, originalName);
const onChangeColumns = (value: string) => setValue(`sections.${id}.columns`, Number(value));
const onResetItems = () => setValue(`sections.${id}.items`, []);
const onRemove = () => removeSection(id);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<DotsThreeVertical weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{hasItems && (
<>
<DropdownMenuItem onClick={onCreate}>
<Plus />
<span className="ml-2">Add a new item</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem onClick={toggleVisibility}>
{section.visible ? <Eye /> : <EyeSlash />}
<span className="ml-2">{section.visible ? "Hide" : "Show"}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PencilSimple />
<span className="ml-2">Rename</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<div className="relative col-span-2">
<Input
id={`sections.${id}.name`}
value={section.name}
onChange={(event) => {
setValue(`sections.${id}.name`, event.target.value);
}}
/>
<Button
size="icon"
variant="link"
onClick={onResetName}
className="absolute inset-y-0 right-0"
>
<ArrowCounterClockwise />
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Columns />
<span className="ml-2">Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={`${section.columns}`} onValueChange={onChangeColumns}>
<DropdownMenuRadioItem value="1">1 Column</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2">2 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">3 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="4">4 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">5 Columns</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasItems} onClick={onResetItems}>
<Broom />
<span className="ml-2">Reset</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-error" disabled={!isCustomSection} onClick={onRemove}>
<TrashSimple />
<span className="ml-2">Remove</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,50 @@
import { Tag } from "@phosphor-icons/react";
import { URL, urlSchema } from "@reactive-resume/schema";
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
import { forwardRef, useMemo } from "react";
interface Props {
id?: string;
value: URL;
placeholder?: string;
onChange: (value: URL) => void;
}
export const URLInput = forwardRef<HTMLInputElement, Props>(
({ id, value, placeholder, onChange }, ref) => {
const hasError = useMemo(() => urlSchema.safeParse(value).success === false, [value]);
return (
<>
<div className="flex gap-x-1">
<Input
id={id}
ref={ref}
value={value.href}
className="flex-1"
hasError={hasError}
placeholder={placeholder}
onChange={(event) => onChange({ ...value, href: event.target.value })}
/>
<Popover>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost">
<Tag />
</Button>
</PopoverTrigger>
<PopoverContent className="p-1.5">
<Input
value={value.label}
placeholder="Label"
onChange={(event) => onChange({ ...value, label: event.target.value })}
/>
</PopoverContent>
</Popover>
</div>
{hasError && <small className="opacity-75">URL must start with https://</small>}
</>
);
},
);

View File

@ -0,0 +1,41 @@
import { defaultSections } from "@reactive-resume/schema";
import { RichInput } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AiActions } from "@/client/components/ai-actions";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "./shared/section-icon";
import { SectionOptions } from "./shared/section-options";
export const SummarySection = () => {
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore(
(state) => state.resume.data.sections.summary ?? defaultSections.summary,
);
return (
<section id="summary" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("summary")}
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
</div>
<div className="flex items-center gap-x-2">
<SectionOptions id="summary" />
</div>
</header>
<main className={cn(!section.visible && "opacity-50")}>
<RichInput
content={section.content}
onChange={(value) => setValue("sections.summary.content", value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</main>
</section>
);
};

View File

@ -0,0 +1,72 @@
import { ScrollArea, Separator } from "@reactive-resume/ui";
import { useRef } from "react";
import { Copyright } from "@/client/components/copyright";
import { ThemeSwitch } from "@/client/components/theme-switch";
import { ExportSection } from "./sections/export";
import { InformationSection } from "./sections/information";
import { LayoutSection } from "./sections/layout";
import { PageSection } from "./sections/page";
import { SharingSection } from "./sections/sharing";
import { StatisticsSection } from "./sections/statistics";
import { TemplateSection } from "./sections/template";
import { ThemeSection } from "./sections/theme";
import { TypographySection } from "./sections/typography";
import { SectionIcon } from "./shared/section-icon";
export const RightSidebar = () => {
const containterRef = useRef<HTMLDivElement | null>(null);
const scrollIntoView = (selector: string) => {
const section = containterRef.current?.querySelector(selector);
section?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
<ScrollArea orientation="vertical" className="h-screen flex-1">
<div ref={containterRef} className="grid gap-y-6 p-6 @container/right">
<TemplateSection />
<Separator />
<LayoutSection />
<Separator />
<TypographySection />
<Separator />
<ThemeSection />
<Separator />
<PageSection />
<Separator />
<SharingSection />
<Separator />
<StatisticsSection />
<Separator />
<ExportSection />
<Separator />
<InformationSection />
<Separator />
<Copyright className="text-center" />
</div>
</ScrollArea>
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
<div />
<div className="flex flex-col items-center justify-center gap-y-2">
<SectionIcon id="template" name="Template" onClick={() => scrollIntoView("#template")} />
<SectionIcon id="layout" name="Layout" onClick={() => scrollIntoView("#layout")} />
<SectionIcon
id="typography"
name="Typography"
onClick={() => scrollIntoView("#typography")}
/>
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
<SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} />
</div>
<ThemeSwitch size={14} />
</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
import { CircleNotch, FileJs, FilePdf } from "@phosphor-icons/react";
import { buttonVariants, Card, CardContent, CardDescription, CardTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { saveAs } from "file-saver";
import { useToast } from "@/client/hooks/use-toast";
import { usePrintResume } from "@/client/services/resume/print";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ExportSection = () => {
const { toast } = useToast();
const { printResume, loading } = usePrintResume();
const onJsonExport = () => {
const { resume } = useResumeStore.getState();
const filename = `reactive_resume-${resume.id}.json`;
const resumeJSON = JSON.stringify(resume.data, null, 2);
saveAs(new Blob([resumeJSON], { type: "application/json" }), filename);
toast({
variant: "success",
title: "A JSON snapshot of your resume has been successfully exported.",
});
};
const onPdfExport = async () => {
const { resume } = useResumeStore.getState();
const { url } = await printResume({ id: resume.id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
return (
<section id="export" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("export")}
<h2 className="line-clamp-1 text-3xl font-bold">Export</h2>
</div>
</header>
<main className="grid gap-y-4">
<Card
onClick={onJsonExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
)}
>
<FileJs size={22} />
<CardContent className="flex-1">
<CardTitle className="text-sm">JSON</CardTitle>
<CardDescription className="font-normal">
Download a JSON snapshot of your resume. This file can be used to import your resume
in the future, or can even be shared with others to collaborate.
</CardDescription>
</CardContent>
</Card>
<Card
onClick={onPdfExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
loading && "pointer-events-none cursor-progress opacity-75",
)}
>
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}
<CardContent className="flex-1">
<CardTitle className="text-sm">PDF</CardTitle>
<CardDescription className="font-normal">
Download a PDF of your resume. This file can be used to print your resume, send it to
recruiters, or upload on job portals.
</CardDescription>
</CardContent>
</Card>
</main>
</section>
);
};

View File

@ -0,0 +1,129 @@
import { Book, EnvelopeSimpleOpen, GithubLogo, HandHeart } from "@phosphor-icons/react";
import {
buttonVariants,
Card,
CardContent,
CardDescription,
CardFooter,
CardTitle,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { getSectionIcon } from "../shared/section-icon";
const DonateCard = () => (
<Card className="space-y-4 bg-info text-info-foreground">
<CardContent className="space-y-2">
<CardTitle>Support the app by donating what you can!</CardTitle>
<CardDescription className="space-y-2">
<p>
I built Reactive Resume mostly by myself during my spare time, with a lot of help from
other great open-source contributors.
</p>
<p>
If you like the app and want to support keeping it free forever, please donate whatever
you can afford to give.
</p>
</CardDescription>
</CardContent>
<CardFooter>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/sponsors/AmruthPillai"
target="_blank"
rel="noopener noreferrer nofollow"
>
<HandHeart size={14} weight="bold" className="mr-2" />
<span>Donate to Reactive Resume</span>
</a>
</CardFooter>
</Card>
);
const IssuesCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Found a bug, or have an idea for a new feature?</CardTitle>
<CardDescription className="space-y-2">
<p>I'm sure the app is not perfect, but I'd like for it to be.</p>
<p>
If you faced any issues while creating your resume, or have an idea that would help you
and other users in creating your resume more easily, drop an issue on the repository or
send me an email about it.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose"
target="_blank"
rel="noopener noreferrer nofollow"
>
<GithubLogo size={14} weight="bold" className="mr-2" />
<span>Raise an issue</span>
</a>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="mailto:hello@amruthpillai.com"
target="_blank"
rel="noopener noreferrer nofollow"
>
<EnvelopeSimpleOpen size={14} weight="bold" className="mr-2" />
<span>Send me a message</span>
</a>
</CardFooter>
</Card>
);
const DocumentationCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Don't know where to begin? Hit the docs!</CardTitle>
<CardDescription className="space-y-2">
<p>
The community has spent a lot of time writing the documentation for Reactive Resume, and
I'm sure it will help you get started with the app.
</p>
<p>
There are also a lot of examples to help you get started, and features that you might not
know about which could help you build your perfect resume.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://docs.rxresu.me/"
target="_blank"
rel="noopener noreferrer nofollow"
>
<Book size={14} weight="bold" className="mr-2" />
<span>Documentation</span>
</a>
</CardFooter>
</Card>
);
export const InformationSection = () => {
return (
<section id="information" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("information")}
<h2 className="line-clamp-1 text-3xl font-bold">Information</h2>
</div>
</header>
<main className="grid gap-y-4">
<DonateCard />
<DocumentationCard />
<IssuesCard />
</main>
</section>
);
};

View File

@ -0,0 +1,269 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowCounterClockwise, DotsSixVertical, Plus, TrashSimple } from "@phosphor-icons/react";
import { defaultMetadata } from "@reactive-resume/schema";
import { Button, Portal, Tooltip } from "@reactive-resume/ui";
import {
cn,
LayoutLocator,
moveItemInLayout,
parseLayoutLocator,
SortablePayload,
} from "@reactive-resume/utils";
import get from "lodash.get";
import { useState } from "react";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
type ColumnProps = {
id: string;
name: string;
items: string[];
};
const Column = ({ id, name, items }: ColumnProps) => {
const { setNodeRef } = useDroppable({ id });
return (
<SortableContext id={id} items={items} strategy={verticalListSortingStrategy}>
<div className="relative">
<div className="absolute inset-0 w-3/4 rounded bg-secondary/50" />
<div className="relative z-10 p-3 pb-8">
<p className="mb-3 text-xs font-bold">{name}</p>
<div ref={setNodeRef} className="space-y-3">
{items.map((section) => (
<SortableSection key={section} id={section} />
))}
</div>
</div>
</div>
</SortableContext>
);
};
type SortableSectionProps = {
id: string;
};
const SortableSection = ({ id }: SortableSectionProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transition,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
<Section id={id} />
</div>
);
};
type SectionProps = {
id: string;
isDragging?: boolean;
};
const Section = ({ id, isDragging = false }: SectionProps) => {
const name = useResumeStore((state) =>
get(state.resume.data.sections, `${id}.name`, id),
) as string;
return (
<div
className={cn(
"cursor-grab rounded bg-primary p-2 text-primary-foreground transition-colors hover:bg-primary-accent",
isDragging && "cursor-grabbing",
)}
>
<div className="flex items-center gap-x-2">
<DotsSixVertical size={12} weight="bold" />
<p className="flex-1 truncate text-xs font-medium">{name}</p>
</div>
</div>
);
};
export const LayoutSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const layout = useResumeStore((state) => state.resume.data.metadata.layout);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onDragStart = ({ active }: DragStartEvent) => {
setActiveId(active.id as string);
};
const onDragCancel = () => {
setActiveId(null);
};
const onDragEvent = ({ active, over }: DragOverEvent | DragEndEvent) => {
if (!over || !active.data.current) return;
const currentPayload = active.data.current.sortable as SortablePayload | null;
const current = parseLayoutLocator(currentPayload);
if (active.id === over.id) return;
if (!over.data.current) {
const [page, column] = (over.id as string).split(".").map(Number);
const target = { page, column, section: 0 } as LayoutLocator;
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
return;
}
const targetPayload = over.data.current.sortable as SortablePayload | null;
const target = parseLayoutLocator(targetPayload);
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
};
const onDragEnd = (event: DragEndEvent) => {
onDragEvent(event);
setActiveId(null);
};
const onAddPage = () => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy.push([[], []]);
setValue("metadata.layout", layoutCopy);
};
const onRemovePage = (page: number) => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy[0][0].push(...layoutCopy[page][0]); // Main
layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar
layoutCopy.splice(page, 1);
setValue("metadata.layout", layoutCopy);
};
const onResetLayout = () => {
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][];
// Loop through all pages and columns, and get any sections that start with "custom."
// These should be appended to the first page of the new layout.
const customSections: string[] = [];
layout.forEach((page) => {
page.forEach((column) => {
customSections.push(...column.filter((section) => section.startsWith("custom.")));
});
});
if (customSections.length > 0) layoutCopy[0][0].push(...customSections);
setValue("metadata.layout", layoutCopy);
};
return (
<section id="layout" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("layout")}
<h2 className="line-clamp-1 text-3xl font-bold">Layout</h2>
</div>
<Tooltip content="Reset Layout">
<Button size="icon" variant="ghost" onClick={onResetLayout}>
<ArrowCounterClockwise />
</Button>
</Tooltip>
</header>
<main className="grid gap-y-4">
{/* Pages */}
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
{layout.map((page, pageIndex) => {
const mainIndex = `${pageIndex}.0`;
const sidebarIndex = `${pageIndex}.1`;
const main = page[0];
const sidebar = page[1];
return (
<div key={pageIndex} className="rounded border p-3 pb-4">
<div className="flex items-center justify-between">
<p className="mb-3 text-xs font-bold">Page {pageIndex + 1}</p>
{pageIndex !== 0 && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => onRemovePage(pageIndex)}
>
<TrashSimple size={12} />
</Button>
)}
</div>
<div className="grid grid-cols-2 items-start gap-x-4">
<Column id={mainIndex} name="Main" items={main} />
<Column id={sidebarIndex} name="Sidebar" items={sidebar} />
</div>
</div>
);
})}
<Portal>
<DragOverlay>{activeId && <Section id={activeId} isDragging />}</DragOverlay>
</Portal>
</DndContext>
<Button variant="outline" className="ml-auto" onClick={onAddPage}>
<Plus />
<span className="ml-2">Add New Page</span>
</Button>
</main>
</section>
);
};

View File

@ -0,0 +1,97 @@
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Slider,
Switch,
} from "@reactive-resume/ui";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const PageSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const page = useResumeStore((state) => state.resume.data.metadata.page);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("page")}
<h2 className="line-clamp-1 text-3xl font-bold">Page</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<Label>Format</Label>
<Select
value={page.format}
onValueChange={(value) => {
setValue("metadata.page.format", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a4">A4</SelectItem>
<SelectItem value="letter">Letter</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Margin</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={48}
step={2}
value={[page.margin]}
onValueChange={(value) => {
setValue("metadata.page.margin", value[0]);
}}
/>
<span className="text-base font-bold">{page.margin}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.breakLine"
checked={page.options.breakLine}
onCheckedChange={(checked) => {
setValue("metadata.page.options.breakLine", checked);
}}
/>
<Label htmlFor="metadata.page.options.breakLine">Show Break Line</Label>
</div>
</div>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.pageNumbers"
checked={page.options.pageNumbers}
onCheckedChange={(checked) => {
setValue("metadata.page.options.pageNumbers", checked);
}}
/>
<Label htmlFor="metadata.page.options.pageNumbers">Show Page Numbers</Label>
</div>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,90 @@
import { CopySimple } from "@phosphor-icons/react";
import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useToast } from "@/client/hooks/use-toast";
import { useUser } from "@/client/services/user";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const SharingSection = () => {
const { user } = useUser();
const { toast } = useToast();
const username = user?.username;
const setValue = useResumeStore((state) => state.setValue);
const slug = useResumeStore((state) => state.resume.slug);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
// Constants
const url = `${window.location.origin}/${username}/${slug}`;
const onCopy = async () => {
await navigator.clipboard.writeText(url);
toast({
variant: "success",
title: "A link has been copied to your clipboard.",
description:
"Anyone with this link can view and download the resume. Share it on your profile or with recruiters.",
});
};
return (
<section id="sharing" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("sharing")}
<h2 className="line-clamp-1 text-3xl font-bold">Sharing</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<div className="flex items-center gap-x-4">
<Switch
id="visibility"
checked={isPublic}
onCheckedChange={(checked) => {
setValue("visibility", checked ? "public" : "private");
}}
/>
<div>
<Label htmlFor="visibility" className="space-y-1">
<p>Public</p>
<p className="text-xs opacity-60">
Anyone with the link can view and download the resume.
</p>
</Label>
</div>
</div>
</div>
<AnimatePresence presenceAffectsLayout>
{isPublic && (
<motion.div
layout
className="space-y-1.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Label htmlFor="resume-url">URL</Label>
<div className="flex gap-x-1.5">
<Input id="resume-url" readOnly value={url} className="flex-1" />
<Tooltip content="Copy to Clipboard">
<Button size="icon" variant="ghost" onClick={onCopy}>
<CopySimple />
</Button>
</Tooltip>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</section>
);
};

View File

@ -0,0 +1,65 @@
import { Info } from "@phosphor-icons/react";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useResumeStatistics } from "@/client/services/resume";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const StatisticsSection = () => {
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
const { statistics } = useResumeStatistics(id, isPublic);
return (
<section id="statistics" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("statistics")}
<h2 className="line-clamp-1 text-3xl font-bold">Statistics</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
<AnimatePresence>
{!isPublic && (
<motion.div
className="col-span-2"
initial={{ opacity: 0, y: -50, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -50, filter: "blur(10px)" }}
>
<Alert variant="info">
<Info size={18} />
<AlertTitle>Statistics are available only for public resumes.</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
You can track the number of views your resume has received, or how many people
have downloaded the resume by enabling public sharing.
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.views ?? 0}
</h3>
<p className="opacity-75">Views</p>
</div>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.downloads ?? 0}
</h3>
<p className="opacity-75">Downloads</p>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,42 @@
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const TemplateSection = () => {
// TODO: Import templates from @reactive-resume/templates
const templateList = ["rhyhorn"];
const setValue = useResumeStore((state) => state.setValue);
const currentTemplate = useResumeStore((state) => state.resume.data.metadata.template);
return (
<section id="template" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("template")}
<h2 className="line-clamp-1 text-3xl font-bold">Template</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
{templateList.map((template) => (
<Button
key={template}
variant="outline"
disabled={template === currentTemplate}
onClick={() => setValue("metadata.template", template)}
className={cn(
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm capitalize ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
template === currentTemplate && "ring-1",
)}
>
{template}
</Button>
))}
</main>
</section>
);
};

View File

@ -0,0 +1,133 @@
import { Input, Label, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { HexColorPicker } from "react-colorful";
import { colors } from "@/client/constants/colors";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ThemeSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const theme = useResumeStore((state) => state.resume.data.metadata.theme);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("theme")}
<h2 className="line-clamp-1 text-3xl font-bold">Theme</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="mb-2 grid grid-cols-6 flex-wrap justify-items-center gap-y-4 @xs/right:grid-cols-9">
{colors.map((color) => (
<div
key={color}
onClick={() => {
setValue("metadata.theme.primary", color);
}}
className={cn(
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ring-primary ring-offset-1 ring-offset-background transition-shadow hover:ring-1",
theme.primary === color && "ring-1",
)}
>
<div className="h-5 w-5 rounded-full" style={{ backgroundColor: color }} />
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Primary Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.primary }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.primary}
onChange={(color) => {
setValue("metadata.theme.primary", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.primary"
value={theme.primary}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.primary", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Background Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.background }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.background}
onChange={(color) => {
setValue("metadata.theme.background", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.background"
value={theme.background}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.background", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Text Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.text }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.text}
onChange={(color) => {
setValue("metadata.theme.text", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.text"
value={theme.text}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.text", event.target.value);
}}
/>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,183 @@
import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { fonts } from "@reactive-resume/utils";
import { useCallback, useEffect, useState } from "react";
import webfontloader from "webfontloader";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
const fontSuggestions = [
"Open Sans",
"Merriweather",
"CMU Serif",
"Playfair Display",
"Lato",
"Lora",
"PT Sans",
"PT Serif",
"IBM Plex Sans",
"IBM Plex Serif",
];
const families: ComboboxOption[] = fonts.map((font) => ({
value: font.family,
label: font.family,
}));
export const TypographySection = () => {
const [subsets, setSubsets] = useState<ComboboxOption[]>([]);
const [variants, setVariants] = useState<ComboboxOption[]>([]);
const setValue = useResumeStore((state) => state.setValue);
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
const loadFontSuggestions = useCallback(async () => {
fontSuggestions.forEach((font) => {
if (font === "CMU Serif") return;
webfontloader.load({
events: false,
classes: false,
google: { families: [font], text: font },
});
});
}, [fontSuggestions]);
useEffect(() => {
loadFontSuggestions();
}, []);
useEffect(() => {
const subsets = fonts.find((font) => font.family === typography.font.family)?.subsets ?? [];
setSubsets(subsets.map((subset) => ({ value: subset, label: subset })));
const variants = fonts.find((font) => font.family === typography.font.family)?.variants ?? [];
setVariants(variants.map((variant) => ({ value: variant, label: variant })));
}, [typography.font.family]);
return (
<section id="typography" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("typography")}
<h2 className="line-clamp-1 text-3xl font-bold">Typography</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="grid grid-cols-2 gap-4">
{fontSuggestions.map((font) => (
<Button
key={font}
variant="outline"
style={{ fontFamily: font }}
disabled={typography.font.family === font}
onClick={() => {
setValue("metadata.typography.font.family", font);
}}
className={cn(
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
typography.font.family === font && "ring-1",
)}
>
{font}
</Button>
))}
</div>
<div className="space-y-1.5">
<Label>Font Family</Label>
<Combobox
options={families}
value={typography.font.family}
searchPlaceholder="Search for a font family"
onValueChange={(value) => {
setValue("metadata.typography.font.family", value);
setValue("metadata.typography.font.subset", "latin");
setValue("metadata.typography.font.variants", ["regular"]);
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="space-y-1.5">
<Label>Font Subset</Label>
<Combobox
options={subsets}
value={typography.font.subset}
searchPlaceholder="Search for a font subset"
onValueChange={(value) => {
setValue("metadata.typography.font.subset", value);
}}
/>
</div>
<div className="space-y-1.5">
<Label>Font Variants</Label>
<Combobox
multiple
options={variants}
value={typography.font.variants}
searchPlaceholder="Search for a font variant"
onValueChange={(value) => {
setValue("metadata.typography.font.variants", value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Font Size</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={12}
max={18}
step={1}
value={[typography.font.size]}
onValueChange={(value) => {
setValue("metadata.typography.font.size", value[0]);
}}
/>
<span className="text-base font-bold">{typography.font.size}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Line Height</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={3}
step={0.25}
value={[typography.lineHeight]}
onValueChange={(value) => {
setValue("metadata.typography.lineHeight", value[0]);
}}
/>
<span className="text-base font-bold">{typography.lineHeight}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.typography.underlineLinks"
checked={typography.underlineLinks}
onCheckedChange={(checked) => {
setValue("metadata.typography.underlineLinks", checked);
}}
/>
<Label htmlFor="metadata.typography.underlineLinks">Underline Links</Label>
</div>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,65 @@
import {
DiamondsFour,
DownloadSimple,
IconProps,
Info,
Layout,
Palette,
ReadCvLogo,
ShareFat,
TextT,
TrendUp,
} from "@phosphor-icons/react";
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
export type MetadataKey =
| "template"
| "layout"
| "typography"
| "theme"
| "page"
| "sharing"
| "statistics"
| "export"
| "information";
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
switch (id) {
// Left Sidebar
case "template":
return <DiamondsFour size={18} {...props} />;
case "layout":
return <Layout size={18} {...props} />;
case "typography":
return <TextT size={18} {...props} />;
case "theme":
return <Palette size={18} {...props} />;
case "page":
return <ReadCvLogo size={18} {...props} />;
case "sharing":
return <ShareFat size={18} {...props} />;
case "statistics":
return <TrendUp size={18} {...props} />;
case "export":
return <DownloadSimple size={18} {...props} />;
case "information":
return <Info size={18} {...props} />;
default:
return null;
}
};
type SectionIconProps = ButtonProps & {
id: MetadataKey;
name: string;
icon?: React.ReactNode;
};
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => (
<Tooltip side="left" content={name}>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
{icon ?? getSectionIcon(id, { size: 14 })}
</Button>
</Tooltip>
);

View File

@ -0,0 +1,129 @@
import { FadersHorizontal, ReadCvLogo } from "@phosphor-icons/react";
import { Button, KeyboardShortcut, Separator } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { motion } from "framer-motion";
import { Link, useLocation, useNavigate } from "react-router-dom";
import useKeyboardShortcut from "use-keyboard-shortcut";
import { Copyright } from "@/client/components/copyright";
import { Icon } from "@/client/components/icon";
import { UserAvatar } from "@/client/components/user-avatar";
import { UserOptions } from "@/client/components/user-options";
import { useUser } from "@/client/services/user";
type Props = {
className?: string;
};
const ActiveIndicator = ({ className }: Props) => (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className={cn(
"h-1.5 w-1.5 animate-pulse rounded-full bg-info shadow-[0_0_12px] shadow-info",
className,
)}
/>
);
interface SidebarItem {
path: string;
name: string;
shortcut?: string;
icon: React.ReactNode;
}
const sidebarItems: SidebarItem[] = [
{
path: "/dashboard/resumes",
name: "Resumes",
shortcut: "⇧R",
icon: <ReadCvLogo />,
},
{
path: "/dashboard/settings",
name: "Settings",
shortcut: "⇧S",
icon: <FadersHorizontal />,
},
];
type SidebarItemProps = SidebarItem & {
onClick?: () => void;
};
const SidebarItem = ({ path, name, shortcut, icon, onClick }: SidebarItemProps) => {
const isActive = useLocation().pathname === path;
return (
<Button
asChild
size="lg"
variant="ghost"
onClick={onClick}
className={cn(
"h-auto justify-start px-4 py-3",
isActive && "pointer-events-none bg-secondary/50 text-secondary-foreground",
)}
>
<Link to={path}>
<div className="mr-3">{icon}</div>
<span>{name}</span>
{!isActive && <KeyboardShortcut className="ml-auto">{shortcut}</KeyboardShortcut>}
{isActive && <ActiveIndicator className="ml-auto" />}
</Link>
</Button>
);
};
type SidebarProps = {
setOpen?: (open: boolean) => void;
};
export const Sidebar = ({ setOpen }: SidebarProps) => {
const { user } = useUser();
const navigate = useNavigate();
useKeyboardShortcut(["shift", "r"], () => {
navigate("/dashboard/resumes");
setOpen?.(false);
});
useKeyboardShortcut(["shift", "s"], () => {
navigate("/dashboard/settings");
setOpen?.(false);
});
return (
<div className="flex h-full flex-col gap-y-4">
<div className="ml-12 flex justify-center lg:ml-0">
<Button asChild size="icon" variant="ghost" className="h-10 w-10 p-0">
<Link to="/">
<Icon size={24} className="mx-auto hidden lg:block" />
</Link>
</Button>
</div>
<Separator className="opacity-50" />
<div className="grid gap-y-2">
{sidebarItems.map((item) => (
<SidebarItem {...item} key={item.path} onClick={() => setOpen?.(false)} />
))}
</div>
<div className="flex-1" />
<Separator className="opacity-50" />
<UserOptions>
<Button size="lg" variant="ghost" className="w-full justify-start px-3">
<UserAvatar size={24} className="mr-3" />
<span>{user?.name}</span>
</Button>
</UserOptions>
<Copyright className="ml-2" />
</div>
);
};

View File

@ -0,0 +1,49 @@
import { SidebarSimple } from "@phosphor-icons/react";
import { Button, Sheet, SheetClose, SheetContent, SheetTrigger } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Sidebar } from "./_components/sidebar";
export const DashboardLayout = () => {
const [open, setOpen] = useState(false);
return (
<div>
<div className="sticky top-0 z-50 flex items-center justify-between p-4 lg:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="icon" variant="ghost" className="bg-background">
<SidebarSimple />
</Button>
</SheetTrigger>
<SheetContent showClose={false} side="left" className="focus-visible:outline-none">
<SheetClose asChild className="absolute left-4 top-4">
<Button size="icon" variant="ghost">
<SidebarSimple />
</Button>
</SheetClose>
<Sidebar setOpen={setOpen} />
</SheetContent>
</Sheet>
</div>
<motion.div
initial={{ x: -320 }}
animate={{ x: 0 }}
className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[320px] lg:flex-col"
>
<div className="h-full rounded p-4">
<Sidebar />
</div>
</motion.div>
<main className="mx-6 my-4 lg:mx-8 lg:pl-[320px]">
<Outlet />
</main>
</div>
);
};

View File

@ -0,0 +1,315 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, DownloadSimple, Warning } from "@phosphor-icons/react";
import {
JsonResume,
JsonResumeParser,
LinkedIn,
LinkedInParser,
ReactiveResumeParser,
ReactiveResumeV3,
ReactiveResumeV3Parser,
} from "@reactive-resume/parser";
import { ResumeData } from "@reactive-resume/schema";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@reactive-resume/ui";
import { AnimatePresence } from "framer-motion";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z, ZodError } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useImportResume } from "@/client/services/resume/import";
import { useDialog } from "@/client/stores/dialog";
enum ImportType {
"reactive-resume-json" = "reactive-resume-json",
"reactive-resume-v3-json" = "reactive-resume-v3-json",
"json-resume-json" = "json-resume-json",
"linkedin-data-export-zip" = "linkedin-data-export-zip",
}
const formSchema = z.object({
file: z.instanceof(File),
type: z.nativeEnum(ImportType),
});
type FormValues = z.infer<typeof formSchema>;
type ValidationResult =
| {
isValid: false;
errors: string;
}
| {
isValid: true;
type: ImportType;
result: ResumeData | ReactiveResumeV3 | LinkedIn | JsonResume;
};
export const ImportDialog = () => {
const { toast } = useToast();
const { isOpen, close } = useDialog("import");
const { importResume, loading, error: importError } = useImportResume();
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
const filetype = form.watch("type");
useEffect(() => {
if (isOpen) onReset();
}, [isOpen]);
useEffect(() => {
form.reset({ file: undefined, type: filetype });
setValidationResult(null);
}, [filetype]);
const accept = useMemo(() => {
if (!filetype) return "";
if (filetype.includes("json")) return ".json";
if (filetype.includes("zip")) return ".zip";
return "";
}, [filetype]);
const onValidate = async () => {
const { file, type } = formSchema.parse(form.getValues());
try {
if (type === ImportType["reactive-resume-json"]) {
const parser = new ReactiveResumeParser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["reactive-resume-v3-json"]) {
const parser = new ReactiveResumeV3Parser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["json-resume-json"]) {
const parser = new JsonResumeParser();
const data = await parser.readFile(file);
const result = parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
if (type === ImportType["linkedin-data-export-zip"]) {
const parser = new LinkedInParser();
const data = await parser.readFile(file);
const result = await parser.validate(data);
setValidationResult({ isValid: true, type, result });
}
} catch (error) {
if (error instanceof ZodError) {
setValidationResult({
isValid: false,
errors: error.toString(),
});
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while validating the file.",
});
}
}
};
const onImport = async () => {
const { type } = formSchema.parse(form.getValues());
if (!validationResult?.isValid || validationResult.type !== type) return;
try {
if (type === ImportType["reactive-resume-json"]) {
const parser = new ReactiveResumeParser();
const data = parser.convert(validationResult.result as ResumeData);
await importResume({ data });
}
if (type === ImportType["reactive-resume-v3-json"]) {
const parser = new ReactiveResumeV3Parser();
const data = parser.convert(validationResult.result as ReactiveResumeV3);
await importResume({ data });
}
if (type === ImportType["json-resume-json"]) {
const parser = new JsonResumeParser();
const data = parser.convert(validationResult.result as JsonResume);
await importResume({ data });
}
if (type === ImportType["linkedin-data-export-zip"]) {
const parser = new LinkedInParser();
const data = parser.convert(validationResult.result as LinkedIn);
await importResume({ data });
}
close();
} catch (error) {
toast({
variant: "error",
icon: <Warning size={16} weight="bold" />,
title: "An error occurred while importing your resume.",
description: importError?.message,
});
}
};
const onReset = () => {
form.reset();
setValidationResult(null);
};
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4">
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
<DownloadSimple />
<h2>Import an existing resume</h2>
</div>
</DialogTitle>
<DialogDescription>
Upload a file from an external source to parse an existing resume and import it into
Reactive Resume for easier editing.
</DialogDescription>
</DialogHeader>
<FormField
name="type"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Please select a file type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="reactive-resume-json">
Reactive Resume (.json)
</SelectItem>
<SelectItem value="reactive-resume-v3-json">
Reactive Resume v3 (.json)
</SelectItem>
<SelectItem value="json-resume-json">JSON Resume (.json)</SelectItem>
<SelectItem value="linkedin-data-export-zip">
LinkedIn Data Export (.zip)
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="file"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Input
type="file"
key={accept}
accept={accept}
onChange={(event) => {
if (!event.target.files || !event.target.files.length) return;
field.onChange(event.target.files[0]);
}}
/>
</FormControl>
<FormMessage />
{accept && <FormDescription>Accepts only {accept} files</FormDescription>}
</FormItem>
)}
/>
{validationResult?.isValid === false && validationResult.errors !== undefined && (
<div className="space-y-2">
<Label className="text-error">Errors during Validation</Label>
<ScrollArea orientation="vertical" className="h-[180px]">
<div className="whitespace-pre-wrap rounded bg-secondary-accent p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(validationResult.errors, null, 4)}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
<AnimatePresence presenceAffectsLayout>
{(!validationResult ?? false) && (
<Button type="button" onClick={onValidate}>
Validate
</Button>
)}
{validationResult !== null && !validationResult.isValid && (
<Button type="button" variant="secondary" onClick={onReset}>
Reset
</Button>
)}
{validationResult !== null && validationResult.isValid && (
<>
<Button type="button" onClick={onImport} disabled={loading}>
Import
</Button>
<Button disabled type="button" variant="success">
<Check size={16} weight="bold" className="mr-2" />
Validated
</Button>
</>
)}
</AnimatePresence>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,247 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { MagicWand, Plus } from "@phosphor-icons/react";
import { createResumeSchema, ResumeDto } from "@reactive-resume/dto";
import { idSchema } from "@reactive-resume/schema";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Tooltip,
} from "@reactive-resume/ui";
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
import { AxiosError } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume";
import { useImportResume } from "@/client/services/resume/import";
import { useDialog } from "@/client/stores/dialog";
const formSchema = createResumeSchema.extend({ id: idSchema.optional() });
type FormValues = z.infer<typeof formSchema>;
export const ResumeDialog = () => {
const { toast } = useToast();
const { isOpen, mode, payload, close } = useDialog<ResumeDto>("resume");
const isCreate = mode === "create";
const isUpdate = mode === "update";
const isDelete = mode === "delete";
const isDuplicate = mode === "duplicate";
const { createResume, loading: createLoading } = useCreateResume();
const { updateResume, loading: updateLoading } = useUpdateResume();
const { deleteResume, loading: deleteLoading } = useDeleteResume();
const { importResume: duplicateResume, loading: duplicateLoading } = useImportResume();
const loading = createLoading || updateLoading || deleteLoading || duplicateLoading;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { title: "", slug: "" },
});
useEffect(() => {
if (isOpen) onReset();
}, [isOpen, payload]);
useEffect(() => {
const slug = kebabCase(form.watch("title"));
form.setValue("slug", slug);
}, [form.watch("title")]);
const onSubmit = async (values: FormValues) => {
try {
if (isCreate) {
await createResume({ slug: values.slug, title: values.title, visibility: "private" });
}
if (isUpdate) {
if (!payload.item?.id) return;
await updateResume({
...payload.item,
title: values.title,
slug: values.slug,
});
}
if (isDuplicate) {
if (!payload.item?.id) return;
await duplicateResume({
title: values.title,
slug: values.slug,
data: payload.item.data,
});
}
if (isDelete) {
if (!payload.item?.id) return;
await deleteResume({ id: payload.item?.id });
}
close();
} catch (error) {
if (error instanceof AxiosError) {
const message = error.response?.data?.message || error.message;
toast({
variant: "error",
title: "An error occurred while trying process your request.",
description: message,
});
}
}
};
const onReset = () => {
if (isCreate) form.reset({ title: "", slug: "" });
if (isUpdate)
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
if (isDuplicate)
form.reset({ title: `${payload.item?.title} (Copy)`, slug: `${payload.item?.slug}-copy` });
if (isDelete)
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
};
const onGenerateRandomName = () => {
const name = generateRandomName();
form.setValue("title", name);
form.setValue("slug", kebabCase(name));
};
if (isDelete) {
return (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<Form {...form}>
<form>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete your resume?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your resume and cannot
be recovered.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
<Plus />
<h2>
{isCreate && "Create a new resume"}
{isUpdate && "Update an existing resume"}
{isDuplicate && "Duplicate an existing resume"}
</h2>
</div>
</DialogTitle>
<DialogDescription>
{isCreate && "Start building your resume by giving it a name."}
{isUpdate && "Changed your mind about the name? Give it a new one."}
{isDuplicate && "Give your old resume a new name."}
</DialogDescription>
</DialogHeader>
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<div className="flex items-center justify-between gap-x-2">
<Input {...field} className="flex-1" />
{(isCreate || isDuplicate) && (
<Tooltip content="Generate a random name">
<Button
size="icon"
type="button"
variant="outline"
onClick={onGenerateRandomName}
>
<MagicWand />
</Button>
</Tooltip>
)}
</div>
</FormControl>
<FormDescription>
Tip: You can name the resume referring to the position you are applying for.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="slug"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={loading}>
{isCreate && "Create"}
{isUpdate && "Save Changes"}
{isDuplicate && "Duplicate"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,25 @@
import { Card } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import Tilt from "react-parallax-tilt";
import { defaultTiltProps } from "@/client/constants/parallax-tilt";
type Props = {
className?: string;
onClick?: () => void;
children?: React.ReactNode;
};
export const BaseCard = ({ children, className, onClick }: Props) => (
<Tilt {...defaultTiltProps}>
<Card
onClick={onClick}
className={cn(
"relative flex aspect-[1/1.4142] scale-100 cursor-pointer items-center justify-center bg-secondary/50 p-0 transition-transform active:scale-95",
className,
)}
>
{children}
</Card>
</Tilt>
);

View File

@ -0,0 +1,31 @@
import { Plus } from "@phosphor-icons/react";
import { KeyboardShortcut } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useDialog } from "@/client/stores/dialog";
import { BaseCard } from "./base-card";
export const CreateResumeCard = () => {
const { open } = useDialog("resume");
return (
<BaseCard onClick={() => open("create")}>
<Plus size={64} weight="thin" />
<div
className={cn(
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
"bg-gradient-to-t from-background/80 to-transparent",
)}
>
<h4 className="font-medium">
Create a new resume
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
</h4>
<p className="text-xs opacity-75">Start from scratch</p>
</div>
</BaseCard>
);
};

View File

@ -0,0 +1,31 @@
import { DownloadSimple } from "@phosphor-icons/react";
import { KeyboardShortcut } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useDialog } from "@/client/stores/dialog";
import { BaseCard } from "./base-card";
export const ImportResumeCard = () => {
const { open } = useDialog("import");
return (
<BaseCard onClick={() => open("create")}>
<DownloadSimple size={64} weight="thin" />
<div
className={cn(
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
"bg-gradient-to-t from-background/80 to-transparent",
)}
>
<h4 className="line-clamp-1 font-medium">
Import an existing resume
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
</h4>
<p className="line-clamp-1 text-xs opacity-75">LinkedIn, JSON Resume, etc.</p>
</div>
</BaseCard>
);
};

View File

@ -0,0 +1,121 @@
import {
CircleNotch,
CopySimple,
FolderOpen,
PencilSimple,
TrashSimple,
} from "@phosphor-icons/react";
import { ResumeDto } from "@reactive-resume/dto";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import dayjs from "dayjs";
import { AnimatePresence, motion } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { useResumePreview } from "@/client/services/resume/preview";
import { useDialog } from "@/client/stores/dialog";
import { BaseCard } from "./base-card";
type Props = {
resume: ResumeDto;
};
export const ResumeCard = ({ resume }: Props) => {
const navigate = useNavigate();
const { open } = useDialog<ResumeDto>("resume");
const { url, loading } = useResumePreview(resume.id);
const lastUpdated = dayjs().to(resume.updatedAt);
const onOpen = () => {
navigate(`/builder/${resume.id}`);
};
const onUpdate = () => {
open("update", { id: "resume", item: resume });
};
const onDuplicate = () => {
open("duplicate", { id: "resume", item: resume });
};
const onDelete = () => {
open("delete", { id: "resume", item: resume });
};
return (
<ContextMenu>
<ContextMenuTrigger>
<BaseCard onClick={onOpen}>
<AnimatePresence presenceAffectsLayout>
{loading && (
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<CircleNotch
size={64}
weight="thin"
opacity={0.5}
className="animate-spin self-center justify-self-center"
/>
</motion.div>
)}
{!loading && url && (
<motion.img
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
src={url}
loading="lazy"
alt={resume.title}
className="h-full w-full object-cover"
/>
)}
</AnimatePresence>
<div
className={cn(
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
"bg-gradient-to-t from-background/80 to-transparent",
)}
>
<h4 className="line-clamp-2 font-medium">{resume.title}</h4>
<p className="line-clamp-1 text-xs opacity-75">{`Last updated ${lastUpdated}`}</p>
</div>
</BaseCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onOpen}>
<FolderOpen size={14} className="mr-2" />
Open
</ContextMenuItem>
<ContextMenuItem onClick={onUpdate}>
<PencilSimple size={14} className="mr-2" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={onDuplicate}>
<CopySimple size={14} className="mr-2" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-error">
<TrashSimple size={14} className="mr-2" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@ -0,0 +1,57 @@
import { sortByDate } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useResumes } from "@/client/services/resume";
import { BaseCard } from "./_components/base-card";
import { CreateResumeCard } from "./_components/create-card";
import { ImportResumeCard } from "./_components/import-card";
import { ResumeCard } from "./_components/resume-card";
export const GridView = () => {
const { resumes, loading } = useResumes();
return (
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<motion.div initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }}>
<CreateResumeCard />
</motion.div>
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0, transition: { delay: 0.1 } }}
>
<ImportResumeCard />
</motion.div>
{loading &&
[...Array(4)].map((_, i) => (
<div
key={i}
className="duration-300 animate-in fade-in"
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
>
<BaseCard />
</div>
))}
{resumes && (
<AnimatePresence>
{resumes
.sort((a, b) => sortByDate(a, b, "updatedAt"))
.map((resume, index) => (
<motion.div
layout
key={resume.id}
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0, transition: { delay: (index + 2) * 0.1 } }}
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
>
<ResumeCard resume={resume} />
</motion.div>
))}
</AnimatePresence>
)}
</div>
);
};

View File

@ -0,0 +1,30 @@
import { cn } from "@reactive-resume/utils";
type Props = {
title?: React.ReactNode;
description?: React.ReactNode;
start?: React.ReactNode;
end?: React.ReactNode;
className?: string;
onClick?: () => void;
};
export const BaseListItem = ({ title, description, start, end, className, onClick }: Props) => (
<div
onClick={onClick}
className={cn(
"flex cursor-pointer items-center rounded p-4 transition-colors hover:bg-secondary/30",
className,
)}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex h-5 w-5 items-center justify-center">{start}</div>
<h4 className="w-[220px] truncate font-medium lg:w-[320px]">{title}</h4>
<p className="hidden text-xs opacity-75 sm:block">{description}</p>
</div>
{end && <div className="flex h-5 w-5 items-center justify-center">{end}</div>}
</div>
</div>
);

View File

@ -0,0 +1,25 @@
import { Plus } from "@phosphor-icons/react";
import { ResumeDto } from "@reactive-resume/dto";
import { KeyboardShortcut } from "@reactive-resume/ui";
import { useDialog } from "@/client/stores/dialog";
import { BaseListItem } from "./base-item";
export const CreateResumeListItem = () => {
const { open } = useDialog<ResumeDto>("resume");
return (
<BaseListItem
start={<Plus size={18} />}
onClick={() => open("create")}
title={
<>
<span>Create a new resume</span>
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
</>
}
description="Start building from scratch"
/>
);
};

View File

@ -0,0 +1,24 @@
import { DownloadSimple } from "@phosphor-icons/react";
import { KeyboardShortcut } from "@reactive-resume/ui";
import { useDialog } from "@/client/stores/dialog";
import { BaseListItem } from "./base-item";
export const ImportResumeListItem = () => {
const { open } = useDialog("import");
return (
<BaseListItem
start={<DownloadSimple size={18} />}
onClick={() => open("create")}
title={
<>
<span>Import an existing resume</span>
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
</>
}
description="LinkedIn, JSON Resume, etc."
/>
);
};

View File

@ -0,0 +1,163 @@
import {
CopySimple,
DotsThreeVertical,
FolderOpen,
PencilSimple,
TrashSimple,
} from "@phosphor-icons/react";
import { ResumeDto } from "@reactive-resume/dto";
import {
Button,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@reactive-resume/ui";
import dayjs from "dayjs";
import { AnimatePresence, motion } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { useResumePreview } from "@/client/services/resume/preview";
import { useDialog } from "@/client/stores/dialog";
import { BaseListItem } from "./base-item";
type Props = {
resume: ResumeDto;
};
export const ResumeListItem = ({ resume }: Props) => {
const navigate = useNavigate();
const { open } = useDialog<ResumeDto>("resume");
const { url } = useResumePreview(resume.id);
const lastUpdated = dayjs().to(resume.updatedAt);
const onOpen = () => {
navigate(`/builder/${resume.id}`);
};
const onUpdate = () => {
open("update", { id: "resume", item: resume });
};
const onDuplicate = () => {
open("duplicate", { id: "resume", item: resume });
};
const onDelete = () => {
open("delete", { id: "resume", item: resume });
};
const dropdownMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild className="aspect-square">
<Button size="icon" variant="ghost">
<DotsThreeVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
>
<FolderOpen size={14} className="mr-2" />
Open
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onUpdate();
}}
>
<PencilSimple size={14} className="mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDuplicate();
}}
>
<CopySimple size={14} className="mr-2" />
Duplicate
</DropdownMenuItem>
<ContextMenuSeparator />
<DropdownMenuItem
className="text-error"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<TrashSimple size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<ContextMenu>
<ContextMenuTrigger className="even:bg-secondary/20">
<HoverCard>
<HoverCardTrigger>
<BaseListItem
onClick={onOpen}
className="group"
title={resume.title}
description={`Last updated ${lastUpdated}`}
end={dropdownMenu}
/>
</HoverCardTrigger>
<HoverCardContent align="end" className="p-0" sideOffset={-100} alignOffset={100}>
<AnimatePresence>
{url && (
<motion.img
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
src={url}
loading="lazy"
alt={resume.title}
className="aspect-[1/1.4142] w-60 rounded-sm object-cover"
/>
)}
</AnimatePresence>
</HoverCardContent>
</HoverCard>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onOpen}>
<FolderOpen size={14} className="mr-2" />
Open
</ContextMenuItem>
<ContextMenuItem onClick={onUpdate}>
<PencilSimple size={14} className="mr-2" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={onDuplicate}>
<CopySimple size={14} className="mr-2" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-error">
<TrashSimple size={14} className="mr-2" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@ -0,0 +1,56 @@
import { sortByDate } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useResumes } from "@/client/services/resume";
import { BaseListItem } from "./_components/base-item";
import { CreateResumeListItem } from "./_components/create-item";
import { ImportResumeListItem } from "./_components/import-item";
import { ResumeListItem } from "./_components/resume-item";
export const ListView = () => {
const { resumes, loading } = useResumes();
return (
<div className="grid gap-y-2">
<motion.div initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}>
<CreateResumeListItem />
</motion.div>
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }}
>
<ImportResumeListItem />
</motion.div>
{loading &&
[...Array(4)].map((_, i) => (
<div
key={i}
className="duration-300 animate-in fade-in"
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
>
<BaseListItem className="bg-secondary/40" />
</div>
))}
{resumes && (
<AnimatePresence>
{resumes
.sort((a, b) => sortByDate(a, b, "updatedAt"))
.map((resume, index) => (
<motion.div
key={resume.id}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: (index + 2) * 0.1 } }}
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
>
<ResumeListItem resume={resume} />
</motion.div>
))}
</AnimatePresence>
)}
</div>
);
};

View File

@ -0,0 +1,54 @@
import { List, SquaresFour } from "@phosphor-icons/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import { useState } from "react";
import { Helmet } from "react-helmet-async";
import { GridView } from "./_layouts/grid";
import { ListView } from "./_layouts/list";
type Layout = "grid" | "list";
export const ResumesPage = () => {
const [layout, setLayout] = useState<Layout>("grid");
return (
<>
<Helmet>
<title>Resumes - Reactive Resume</title>
</Helmet>
<Tabs value={layout} onValueChange={(value) => setLayout(value as Layout)}>
<div className="flex items-center justify-between">
<motion.h1
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
className="text-4xl font-bold tracking-tight"
>
Resumes
</motion.h1>
<TabsList>
<TabsTrigger value="grid" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
<SquaresFour />
<span className="ml-2 hidden sm:block">Grid</span>
</TabsTrigger>
<TabsTrigger value="list" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
<List />
<span className="ml-2 hidden sm:block">List</span>
</TabsTrigger>
</TabsList>
</div>
<div className="mt-12 md:mt-8">
<TabsContent value="grid">
<GridView />
</TabsContent>
<TabsContent value="list">
<ListView />
</TabsContent>
</div>
</Tabs>
</>
);
};

View File

@ -0,0 +1,264 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { QrCode } from "@phosphor-icons/react";
import {
Alert,
AlertDescription,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AxiosError } from "axios";
import { QRCodeSVG } from "qrcode.react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { queryClient } from "@/client/libs/query-client";
import { useDisable2FA, useEnable2FA, useSetup2FA } from "@/client/services/auth";
import { useDialog } from "@/client/stores/dialog";
// We're using the pre-existing "mode" state to determine the stage of 2FA setup the user is in.
// - "create" mode is used to enable 2FA.
// - "update" mode is used to verify 2FA, displaying a QR Code, once enabled.
// - "duplicate" mode is used to display the backup codes after initial verification.
// - "delete" mode is used to disable 2FA.
const formSchema = z.object({
uri: z.literal("").or(z.string().optional()),
code: z.literal("").or(z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits long.")),
backupCodes: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
export const TwoFactorDialog = () => {
const { toast } = useToast();
const { isOpen, mode, open, close } = useDialog("two-factor");
const isCreate = mode === "create";
const isUpdate = mode === "update";
const isDelete = mode === "delete";
const isDuplicate = mode === "duplicate";
const { setup2FA, loading: setupLoading } = useSetup2FA();
const { enable2FA, loading: enableLoading } = useEnable2FA();
const { disable2FA, loading: disableLoading } = useDisable2FA();
const loading = setupLoading || enableLoading || disableLoading;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { uri: "", code: "", backupCodes: [] },
});
// If the user is enabling 2FA, we need to get the QR code URI from the server.
// And display the QR code to the user.
useEffect(() => {
const initialize = async () => {
const data = await setup2FA();
form.setValue("uri", data.message);
};
if (isCreate) initialize();
}, [isCreate]);
const onSubmit = async (values: FormValues) => {
if (isCreate) {
open("update");
}
if (isUpdate) {
if (!values.code) return;
try {
const data = await enable2FA({ code: values.code });
form.setValue("backupCodes", data.backupCodes);
await queryClient.invalidateQueries({ queryKey: ["user"] });
open("duplicate");
} catch (error) {
if (error instanceof AxiosError) {
const message = error.response?.data?.message || error.message;
toast({
variant: "error",
title: "An error occurred while trying to enable two-factor authentication.",
description: message,
});
}
}
}
if (isDuplicate) {
close();
}
if (isDelete) {
const data = await disable2FA();
toast({ variant: "success", title: data.message });
await queryClient.invalidateQueries({ queryKey: ["user"] });
close();
}
};
if (isDelete) {
return (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<Form {...form}>
<form className="space-y-4">
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to disable two-factor authentication?
</AlertDialogTitle>
<AlertDialogDescription>
If you disable two-factor authentication, you will no longer be required to enter
a verification code when logging in.
</AlertDialogDescription>
</AlertDialogHeader>
<Alert variant="info">
<AlertDescription>Note: This will make your account less secure.</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
Disable
</AlertDialogAction>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent className="!max-w-md">
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
<QrCode />
<h2>
{mode === "create" && "Setup two-factor authentication on your account"}
{mode === "update" &&
"Verify that two-factor authentication has been setup correctly"}
{mode === "duplicate" && "Store your backup codes securely"}
</h2>
</div>
</DialogTitle>
<DialogDescription>
{isCreate &&
"Scan the QR code below with your authenticator app to setup 2FA on your account."}
{isUpdate &&
"Enter the 6-digit code from your authenticator app to verify that 2FA has been setup correctly."}
{isDuplicate && "You have enabled two-factor authentication successfully."}
</DialogDescription>
</DialogHeader>
{isCreate && (
<FormField
name="uri"
control={form.control}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="space-y-4">
<QRCodeSVG value={field.value!} size={256} className="mx-auto" />
<Input readOnly {...field} className="opacity-75" />
</div>
</FormControl>
<FormDescription>
In case you don't have access to your camera, you can also copy-paste this URI
to your authenticator app.
</FormDescription>
</FormItem>
)}
/>
)}
{isUpdate && (
<FormField
name="code"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Code</FormLabel>
<FormControl>
<Input type="number" placeholder="123456" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{isDuplicate && (
<>
<FormField
name="backupCodes"
control={form.control}
render={({ field }) => (
<FormItem>
<div className="mx-auto grid max-w-xs grid-cols-2 rounded-sm bg-secondary/50 p-4 text-center font-mono leading-loose">
{field.value.map((code) => (
<p key={code}>{code}</p>
))}
</div>
</FormItem>
)}
/>
<p className="text-xs leading-relaxed">
Please store your backup codes in a secure location. You can use one of these
one-time use codes to login in case you lose access to your authenticator app.
</p>
</>
)}
<DialogFooter>
{isCreate && <Button disabled={loading}>Continue</Button>}
{isUpdate && (
<>
<Button variant="ghost" onClick={() => open("create")}>
Back
</Button>
<Button disabled={loading}>Continue</Button>
</>
)}
{isDuplicate && <Button disabled={loading}>Close</Button>}
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,231 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, UploadSimple, Warning } from "@phosphor-icons/react";
import { UpdateUserDto, updateUserSchema } from "@reactive-resume/dto";
import {
Button,
buttonVariants,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { UserAvatar } from "@/client/components/user-avatar";
import { useToast } from "@/client/hooks/use-toast";
import { useResendVerificationEmail } from "@/client/services/auth";
import { useUploadImage } from "@/client/services/storage";
import { useUpdateUser, useUser } from "@/client/services/user";
export const AccountSettings = () => {
const { user } = useUser();
const { toast } = useToast();
const { updateUser, loading } = useUpdateUser();
const { uploadImage, loading: isUploading } = useUploadImage();
const { resendVerificationEmail } = useResendVerificationEmail();
const inputRef = useRef<HTMLInputElement>(null);
const form = useForm<UpdateUserDto>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
picture: "",
name: "",
username: "",
email: "",
},
});
useEffect(() => {
user && onReset();
}, [user]);
const onReset = () => {
if (!user) return;
form.reset({
picture: user.picture ?? "",
name: user.name,
username: user.username,
email: user.email,
});
};
const onSubmit = async (data: UpdateUserDto) => {
if (!user) return;
// Check if email has changed and display a toast message to confirm the email change
if (user.email !== data.email) {
toast({
variant: "info",
title: "Check your email for the confirmation link to update your email address.",
});
}
await updateUser({
name: data.name,
email: data.email,
picture: data.picture,
username: data.username,
});
form.reset(data);
};
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
const response = await uploadImage(file);
const url = response.data;
await updateUser({ picture: url });
}
};
const onResendVerificationEmail = async () => {
const data = await resendVerificationEmail();
toast({ variant: "success", title: data.message });
};
if (!user) return null;
return (
<div className="space-y-6">
<div>
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Account</h3>
<p className="leading-relaxed opacity-75">
Here, you can update your account information such as your profile picture, name and
username.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
<FormField
name="picture"
control={form.control}
render={({ field, fieldState: { error } }) => (
<div className={cn("flex items-end gap-x-4 sm:col-span-2", error && "items-center")}>
<UserAvatar />
<FormItem className="flex-1">
<FormLabel>Picture</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} value={field.value || ""} />
</FormControl>
<FormMessage />
</FormItem>
{!user.picture && (
<>
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
<motion.button
disabled={isUploading}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => inputRef.current?.click()}
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
>
<UploadSimple />
</motion.button>
</>
)}
</div>
)}
/>
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
name="username"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{fieldState.error && (
<FormDescription className="text-error">
{fieldState.error.message}
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormDescription
className={cn(
"flex items-center gap-x-1.5 font-medium opacity-100",
user.emailVerified ? "text-success-accent" : "text-warning-accent",
)}
>
{user.emailVerified ? <Check size={12} /> : <Warning size={12} />}
{user.emailVerified ? "Verified" : "Unverified"}
{!user.emailVerified && (
<Button
variant="link"
className="h-auto text-xs"
onClick={onResendVerificationEmail}
>
Resend confirmation link
</Button>
)}
</FormDescription>
</FormItem>
)}
/>
<AnimatePresence presenceAffectsLayout>
{form.formState.isDirty && (
<motion.div
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="flex items-center space-x-2 self-center sm:col-start-2"
>
<Button type="submit" disabled={loading}>
Save Changes
</Button>
<Button type="reset" variant="ghost" onClick={onReset}>
Reset
</Button>
</motion.div>
)}
</AnimatePresence>
</form>
</Form>
</div>
);
};

View File

@ -0,0 +1,96 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Input,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useCounter } from "usehooks-ts";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useLogout } from "@/client/services/auth";
import { useDeleteUser } from "@/client/services/user";
const formSchema = z.object({
deleteConfirm: z.literal("delete"),
});
type FormValues = z.infer<typeof formSchema>;
export const DangerZoneSettings = () => {
const { toast } = useToast();
const navigate = useNavigate();
const { logout } = useLogout();
const { count, increment } = useCounter(0);
const { deleteUser, loading } = useDeleteUser();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
deleteConfirm: "" as FormValues["deleteConfirm"],
},
});
const onDelete = async () => {
// On the first click, increment the counter
increment();
// On the second click, delete the account
if (count === 1) {
await Promise.all([deleteUser, logout]);
toast({
variant: "success",
title: "Your account has been deleted successfully.",
});
navigate("/");
}
};
return (
<div className="space-y-6">
<div>
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Danger Zone</h3>
<p className="leading-relaxed opacity-75">
In this section, you can delete your account and all the data associated to your user, but
please keep in mind that{" "}
<span className="font-semibold">this action is irreversible</span>.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onDelete)} className="grid gap-6 sm:grid-cols-2">
<FormField
name="deleteConfirm"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Delete Account</FormLabel>
<FormControl>
<Input placeholder="delete" {...field} />
</FormControl>
<FormDescription>
Type <code className="font-bold">delete</code> to confirm deleting your account.
</FormDescription>
</FormItem>
)}
/>
<div className="flex items-center space-x-2 self-center">
<Button type="submit" variant="error" disabled={!form.formState.isValid || loading}>
{count === 1 ? "Are you sure?" : "Delete Account"}
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@ -0,0 +1,147 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { LockSimple, LockSimpleOpen, TrashSimple } from "@phosphor-icons/react";
import {
Alert,
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useOpenAiStore } from "@/client/stores/openai";
const formSchema = z.object({
apiKey: z
.string()
.regex(/^sk-[a-zA-Z0-9]+$/, "That doesn't look like a valid OpenAI API key.")
.default(""),
});
type FormValues = z.infer<typeof formSchema>;
export const OpenAISettings = () => {
const { apiKey, setApiKey } = useOpenAiStore();
const isEnabled = !!apiKey;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { apiKey: apiKey ?? "" },
});
const onSubmit = async ({ apiKey }: FormValues) => {
setApiKey(apiKey);
};
const onRemove = () => {
setApiKey(null);
form.reset({ apiKey: "" });
};
return (
<div className="space-y-6">
<div>
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">OpenAI Integration</h3>
<p className="leading-relaxed opacity-75">
You can make use of the OpenAI API to help you generate content, or improve your writing
while composing your resume.
</p>
</div>
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
<p>
You have the option to{" "}
<a
href="https://www.howtogeek.com/885918/how-to-get-an-openai-api-key/"
rel="noopener noreferrer nofollow"
target="_blank"
>
obtain your own OpenAI API key
</a>
. This key empowers you to leverage the API as you see fit. Alternatively, if you wish to
disable the AI features in Reactive Resume altogether, you can simply remove the key from
your settings.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
<FormField
name="apiKey"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div
className={cn(
"flex items-center space-x-2 self-end sm:col-start-2",
!!form.formState.errors.apiKey && "self-center",
)}
>
<Button type="submit" disabled={isEnabled || !form.formState.isDirty}>
{!isEnabled && <LockSimpleOpen className="mr-2" />}
{isEnabled && <LockSimple className="mr-2" />}
{isEnabled ? "Saved" : "Save Locally"}
</Button>
{isEnabled && (
<Button type="reset" variant="ghost" onClick={onRemove}>
<TrashSimple className="mr-2" />
Remove
</Button>
)}
</div>
</form>
</Form>
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
<p>
Your API key is securely stored in the browser's local storage and is only utilized when
making requests to OpenAI via their official SDK. Rest assured that your key is not
transmitted to any external server except when interacting with OpenAI's services.
</p>
</div>
<Alert variant="warning">
<div className="prose prose-neutral max-w-full text-xs leading-relaxed text-primary dark:prose-invert">
<span className="font-medium">Note: </span>
<span>
By utilizing the OpenAI API, you acknowledge and accept the{" "}
<a
href="https://openai.com/policies/terms-of-use"
target="_blank"
rel="noopener noreferrer nofollow"
>
terms of use
</a>{" "}
and{" "}
<a
href="https://openai.com/policies/privacy-policy"
target="_blank"
rel="noopener noreferrer nofollow"
>
privacy policy
</a>{" "}
outlined by OpenAI. Please note that Reactive Resume bears no responsibility for any
improper or unauthorized utilization of the service, and any resulting repercussions or
liabilities solely rest on the user.
</span>
</div>
</Alert>
</div>
);
};

View File

@ -0,0 +1,138 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useTheme } from "@reactive-resume/hooks";
import { Button } from "@reactive-resume/ui";
import { Combobox } from "@reactive-resume/ui";
import { Form, FormDescription, FormField, FormItem, FormLabel } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useUpdateUser, useUser } from "@/client/services/user";
const formSchema = z.object({
theme: z.enum(["system", "light", "dark"]).default("system"),
language: z.string().default("en"),
});
type FormValues = z.infer<typeof formSchema>;
export const ProfileSettings = () => {
const { user } = useUser();
const { theme, setTheme } = useTheme();
const { updateUser, loading } = useUpdateUser();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { theme, language: "en" },
});
useEffect(() => {
user && onReset();
}, [user]);
const onReset = () => {
if (!user) return;
form.reset({ theme, language: user.language ?? "en" });
};
const onSubmit = async (data: FormValues) => {
if (!user) return;
setTheme(data.theme);
if (user.language !== data.language) {
await updateUser({ language: data.language });
}
form.reset(data);
};
return (
<div className="space-y-6">
<div>
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Profile</h3>
<p className="leading-relaxed opacity-75">
Here, you can update your profile to customize and personalize your experience.
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
<FormField
name="theme"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<div className="w-full">
<Combobox
{...field}
value={field.value}
onValueChange={field.onChange}
options={[
{ label: "System", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
</div>
</FormItem>
)}
/>
<FormField
name="language"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<div className="w-full">
<Combobox
{...field}
value={field.value}
onValueChange={field.onChange}
options={[
{
value: "en",
label: <p>English</p>,
},
]}
/>
</div>
<FormDescription>
<span>
Don't see your language?{" "}
<a
target="_blank"
rel="noopener noreferrer nofollow"
href="https://translate.rxresu.me/"
className="font-medium underline underline-offset-2"
>
Help translate the app.
</a>
</span>
</FormDescription>
</FormItem>
)}
/>
<div
className={cn(
"hidden items-center space-x-2 self-center sm:col-start-2",
form.formState.isDirty && "flex animate-in fade-in",
)}
>
<Button type="submit" disabled={loading}>
Save Changes
</Button>
<Button type="reset" variant="ghost" onClick={onReset}>
Reset
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@ -0,0 +1,162 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Input,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useToast } from "@/client/hooks/use-toast";
import { useUpdatePassword } from "@/client/services/auth";
import { useUser } from "@/client/services/user";
import { useDialog } from "@/client/stores/dialog";
const formSchema = z
.object({
password: z.string().min(6),
confirmPassword: z.string().min(6),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "The passwords you entered do not match.",
});
type FormValues = z.infer<typeof formSchema>;
export const SecuritySettings = () => {
const { user } = useUser();
const { toast } = useToast();
const { open } = useDialog("two-factor");
const { updatePassword, loading } = useUpdatePassword();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { password: "", confirmPassword: "" },
});
const onReset = () => {
form.reset({ password: "", confirmPassword: "" });
};
const onSubmit = async (data: FormValues) => {
await updatePassword({ password: data.password });
toast({
variant: "success",
title: "Your password has been updated successfully.",
});
onReset();
};
return (
<div className="space-y-6">
<div>
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Security</h3>
<p className="leading-relaxed opacity-75">
In this section, you can change your password and enable/disable two-factor
authentication.
</p>
</div>
<Accordion type="multiple" defaultValue={["password", "two-factor"]}>
<AccordionItem value="password">
<AccordionTrigger>Password</AccordionTrigger>
<AccordionContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
name="confirmPassword"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
{fieldState.error && (
<FormDescription className="text-error-foreground">
{fieldState.error?.message}
</FormDescription>
)}
</FormItem>
)}
/>
<AnimatePresence presenceAffectsLayout>
{form.formState.isDirty && (
<motion.div
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
className="flex items-center space-x-2 self-center sm:col-start-2"
>
<Button type="submit" disabled={loading}>
Change Password
</Button>
<Button type="reset" variant="ghost" onClick={onReset}>
Reset
</Button>
</motion.div>
)}
</AnimatePresence>
</form>
</Form>
</AccordionContent>
</AccordionItem>
<AccordionItem value="two-factor">
<AccordionTrigger>Two-Factor Authentication</AccordionTrigger>
<AccordionContent>
{user?.twoFactorEnabled ? (
<p className="mb-4 leading-relaxed opacity-75">
<strong>Two-factor authentication is enabled.</strong> You will be asked to enter a
code every time you sign in.
</p>
) : (
<p className="mb-4 leading-relaxed opacity-75">
<strong>Two-factor authentication is currently disabled.</strong> You can enable it
by adding an authenticator app to your account.
</p>
)}
{user?.twoFactorEnabled ? (
<Button variant="outline" onClick={() => open("delete")}>
Disable 2FA
</Button>
) : (
<Button variant="outline" onClick={() => open("create")}>
Enable 2FA
</Button>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View File

@ -0,0 +1,37 @@
import { Separator } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import { Helmet } from "react-helmet-async";
import { AccountSettings } from "./_sections/account";
import { DangerZoneSettings } from "./_sections/danger";
import { OpenAISettings } from "./_sections/openai";
import { ProfileSettings } from "./_sections/profile";
import { SecuritySettings } from "./_sections/security";
export const SettingsPage = () => (
<>
<Helmet>
<title>Settings - Reactive Resume</title>
</Helmet>
<div className="max-w-2xl space-y-8 pb-12">
<motion.h1
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
className="text-4xl font-bold tracking-tight"
>
Settings
</motion.h1>
<AccountSettings />
<Separator />
<SecuritySettings />
<Separator />
<ProfileSettings />
<Separator />
<OpenAISettings />
<Separator />
<DangerZoneSettings />
</div>
</>
);

View File

@ -0,0 +1,32 @@
import { Separator } from "@reactive-resume/ui";
import { Copyright } from "@/client/components/copyright";
import { Logo } from "@/client/components/logo";
import { ThemeSwitch } from "@/client/components/theme-switch";
export const Footer = () => (
<footer className="fixed inset-x-0 bottom-0 -z-50 h-[400px] bg-background">
<Separator />
<div className="container grid py-6 sm:grid-cols-3 lg:grid-cols-4">
<div className="flex flex-col gap-y-2">
<Logo size={96} className="-ml-2" />
<h2 className="text-xl font-medium">Reactive Resume</h2>
<p className="prose prose-sm prose-zinc leading-relaxed opacity-60 dark:prose-invert">
A free and open-source resume builder that simplifies the tasks of creating, updating, and
sharing your resume.
</p>
<Copyright className="mt-6" />
</div>
<div className="relative col-start-4">
<div className="absolute bottom-0 right-0">
<ThemeSwitch />
</div>
</div>
</div>
</footer>
);

View File

@ -0,0 +1,23 @@
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
import { Logo } from "@/client/components/logo";
export const Header = () => (
<motion.header
className="fixed inset-x-0 top-0 z-20"
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
>
<div className="bg-gradient-to-b from-background to-transparent py-3">
<div className="container flex items-center justify-between">
<Link to="/">
<Logo size={48} />
</Link>
<div />
</div>
</div>
</motion.header>
);

View File

@ -0,0 +1,12 @@
import { Outlet } from "react-router-dom";
import { Footer } from "./components/footer";
import { Header } from "./components/header";
export const HomeLayout = () => (
<>
<Header />
<Outlet />
<Footer />
</>
);

View File

@ -0,0 +1,17 @@
import { Helmet } from "react-helmet-async";
import { HeroSection } from "./sections/hero";
import { LogoCloudSection } from "./sections/logo-cloud";
import { StatisticsSection } from "./sections/statistics";
export const HomePage = () => (
<main className="relative isolate mb-[400px] overflow-hidden bg-background">
<Helmet>
<title>Reactive Resume - A free and open-source resume builder</title>
</Helmet>
<HeroSection />
<LogoCloudSection />
<StatisticsSection />
</main>
);

View File

@ -0,0 +1,46 @@
import { Book, SignOut } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui";
import { Link } from "react-router-dom";
import { useLogout } from "@/client/services/auth";
import { useAuthStore } from "@/client/stores/auth";
export const HeroCTA = () => {
const { logout } = useLogout();
const isLoggedIn = useAuthStore((state) => !!state.user);
if (isLoggedIn) {
return (
<>
<Button asChild size="lg">
<Link to="/dashboard">Go to Dashboard</Link>
</Button>
<Button size="lg" variant="link" onClick={() => logout()}>
<SignOut className="mr-3" />
Logout
</Button>
</>
);
}
if (!isLoggedIn) {
return (
<>
<Button asChild size="lg">
<Link to="/auth/login">Get started</Link>
</Button>
<Button asChild size="lg" variant="link">
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer nofollow">
<Book className="mr-3" />
Learn more
</a>
</Button>
</>
);
}
return null;
};

View File

@ -0,0 +1,47 @@
export const Decoration = {
Grid: () => (
<svg
aria-hidden="true"
className="absolute inset-0 -z-10 h-full w-full stroke-foreground/10 opacity-60 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)] dark:opacity-40"
>
<defs>
<pattern
id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg x="50%" y={-1} className="overflow-visible fill-border/20">
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)"
/>
</svg>
),
Gradient: () => (
<div
aria-hidden="true"
className="absolute left-[calc(50%-4rem)] top-10 -z-10 transform-gpu blur-3xl sm:left-[calc(50%-18rem)] lg:left-48 lg:top-[calc(50%-30rem)] xl:left-[calc(50%-24rem)]"
>
<div
className="aspect-[1108/632] h-96 w-[69.25rem] bg-gradient-to-r from-[#6f8cbb] to-[#c93b37] opacity-40 dark:opacity-20"
style={{
clipPath:
"polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)",
}}
/>
</div>
),
};

View File

@ -0,0 +1,65 @@
import { ArrowRight } from "@phosphor-icons/react";
import { Badge, Button } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import Tilt from "react-parallax-tilt";
import { defaultTiltProps } from "@/client/constants/parallax-tilt";
import { HeroCTA } from "./call-to-action";
import { Decoration } from "./decoration";
export const HeroSection = () => (
<section className="relative">
<Decoration.Grid />
<Decoration.Gradient />
<div className="mx-auto max-w-7xl px-6 lg:flex lg:h-screen lg:items-center lg:px-12">
<motion.div
className="mx-auto max-w-3xl shrink-0 lg:mx-0 lg:max-w-xl lg:pt-8"
initial={{ opacity: 0, x: -100 }}
whileInView={{ opacity: 1, x: 0 }}
>
<div className="mt-24 flex items-center gap-x-4 sm:mt-32 lg:mt-0">
<Badge>Version 4</Badge>
<Button variant="link" className="space-x-2 text-left">
<p>What's new in the latest version</p>
<ArrowRight />
</Button>
</div>
<div className="mt-10 space-y-2">
<h6 className="text-base font-bold tracking-wide">Finally,</h6>
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
A free and open-source resume builder.
</h1>
</div>
<p className="prose prose-base prose-zinc mt-6 text-lg leading-8 dark:prose-invert">
A free and open-source resume builder that simplifies the process of creating, updating,
and sharing your resume.
</p>
<div className="mt-10 flex items-center gap-x-8">
<HeroCTA />
</div>
</motion.div>
<div className="mx-auto mt-16 flex max-w-2xl sm:mt-24 lg:ml-10 lg:mr-0 lg:mt-0 lg:max-w-none lg:flex-none xl:ml-20">
<div className="max-w-3xl flex-none sm:max-w-5xl lg:max-w-none">
<motion.div initial={{ opacity: 0, x: 100 }} whileInView={{ opacity: 1, x: 0 }}>
<Tilt {...defaultTiltProps}>
<img
width={3600}
height={2078}
src="/screenshots/builder.png"
alt="Reactive Resume - Screenshot - Builder Screen"
className="w-[76rem] rounded-lg bg-background/5 shadow-2xl ring-1 ring-foreground/10"
/>
</Tilt>
</motion.div>
</div>
</div>
</div>
</section>
);

View File

@ -0,0 +1,60 @@
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
type LogoProps = { company: string };
const Logo = ({ company }: LogoProps) => (
<div
className={cn(
"col-span-2 col-start-2 sm:col-start-auto lg:col-span-1",
company === "twilio" && "sm:col-start-2",
)}
>
{/* Show on Light Theme */}
<img
className="block max-h-12 object-contain dark:hidden"
src={`/brand-logos/dark/${company}.svg`}
alt={company}
width={212}
height={48}
/>
{/* Show on Dark Theme */}
<img
className="hidden max-h-12 object-contain dark:block"
src={`/brand-logos/light/${company}.svg`}
alt={company}
width={212}
height={48}
/>
</div>
);
const logoList: string[] = ["amazon", "google", "postman", "twilio", "zalando"];
export const LogoCloudSection = () => (
<section className="relative py-24 sm:py-32">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<p className="text-center text-lg leading-relaxed">
Reactive Resume has helped people land jobs at these great companies:
</p>
<div className="mx-auto mt-10 grid max-w-lg grid-cols-4 items-center gap-x-8 gap-y-10 sm:max-w-xl sm:grid-cols-6 sm:gap-x-10 lg:mx-0 lg:max-w-none lg:grid-cols-5">
{logoList.map((company) => (
<Logo key={company} company={company} />
))}
</div>
<p className="mx-auto mt-8 max-w-sm text-center leading-relaxed">
If this app has helped you with your job hunt, let me know by reaching out through{" "}
<Button asChild variant="link" className="p-0">
<a
href="https://www.amruthpillai.com/#contact"
target="_blank"
rel="noopener noreferrer nofollow"
>
this contact form
</a>
</Button>
.
</p>
</div>
</section>
);

View File

@ -0,0 +1,57 @@
import { animate, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
type CounterProps = { from: number; to: number };
export const Counter = ({ from, to }: CounterProps) => {
const [isInView, setIsInView] = useState(false);
const nodeRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const node = nodeRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsInView(true);
}
});
},
{ threshold: 0.1 },
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, []);
useEffect(() => {
if (!isInView) return;
const node = nodeRef.current;
if (!node) return;
const controls = animate(from, to, {
duration: 1,
onUpdate(value) {
node.textContent = Math.round(value).toLocaleString();
},
});
return () => controls.stop();
}, [from, to, isInView]);
return (
<motion.span
ref={nodeRef}
transition={{ duration: 0.5 }}
initial={{ opacity: 0, scale: 0.1 }}
animate={isInView ? { opacity: 1, scale: 1 } : {}}
/>
);
};

View File

@ -0,0 +1,29 @@
import { Counter } from "./counter";
type Statistic = {
name: string;
value: number;
};
const stats: Statistic[] = [
{ name: "GitHub Stars", value: 11800 },
{ name: "Users Signed Up", value: 300000 },
{ name: "Resumes Generated", value: 400000 },
];
export const StatisticsSection = () => (
<section className="relative py-24 sm:py-32">
<div className="mx-auto max-w-7xl px-6 lg:px-8">
<dl className="grid grid-cols-1 gap-x-8 gap-y-16 text-center lg:grid-cols-3">
{stats.map((stat, index) => (
<div key={index} className="mx-auto flex max-w-xs flex-col gap-y-4">
<dt className="text-base leading-7 opacity-60">{stat.name}</dt>
<dd className="order-first text-3xl font-semibold tracking-tight sm:text-5xl">
<Counter from={0} to={stat.value} />+
</dd>
</div>
))}
</dl>
</div>
</section>
);

View File

@ -0,0 +1,20 @@
import { ResumeData, SectionKey } from "@reactive-resume/schema";
import { Artboard, PageWrapper, Rhyhorn } from "@reactive-resume/templates";
import { Navigate } from "react-router-dom";
import { useSessionStorage } from "usehooks-ts";
export const PrinterPage = () => {
const [resume] = useSessionStorage<ResumeData | null>("resume", null);
if (!resume) return <Navigate to="/" replace />;
return (
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
{resume.metadata.layout.map((columns, pageIndex) => (
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
</PageWrapper>
))}
</Artboard>
);
};

View File

@ -0,0 +1,74 @@
import { ResumeDto } from "@reactive-resume/dto";
import { SectionKey } from "@reactive-resume/schema";
import { Artboard, PageWrapper, Rhyhorn } from "@reactive-resume/templates";
import { Button } from "@reactive-resume/ui";
import { pageSizeMap } from "@reactive-resume/utils";
import { Helmet } from "react-helmet-async";
import { Link, LoaderFunction, redirect, useLoaderData } from "react-router-dom";
import { Icon } from "@/client/components/icon";
import { ThemeSwitch } from "@/client/components/theme-switch";
import { toast } from "@/client/hooks/use-toast";
import { queryClient } from "@/client/libs/query-client";
import { findResumeByUsernameSlug } from "@/client/services/resume";
export const PublicResumePage = () => {
const { title, data: resume } = useLoaderData() as ResumeDto;
const format = resume.metadata.page.format;
return (
<div>
<Helmet>
<title>{title} - Reactive Resume</title>
</Helmet>
<div
style={{ width: `${pageSizeMap[format].width}mm` }}
className="mx-auto mb-6 mt-16 flex shadow-xl print:m-0 print:shadow-none"
>
<Artboard resume={resume} style={{ pointerEvents: "auto" }}>
{resume.metadata.layout.map((columns, pageIndex) => (
<PageWrapper key={pageIndex} data-page={pageIndex + 1}>
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
</PageWrapper>
))}
</Artboard>
</div>
<div className="flex justify-center py-10 opacity-50 print:hidden">
<Link to="/">
<Button size="sm" variant="ghost" className="space-x-1.5 text-xs font-normal">
<span>Built with</span>
<Icon size={12} />
<span>Reactive Resume</span>
</Button>
</Link>
</div>
<div className="fixed bottom-5 right-5 print:hidden">
<ThemeSwitch />
</div>
</div>
);
};
export const publicLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
try {
const username = params.username as string;
const slug = params.slug as string;
const resume = await queryClient.fetchQuery({
queryKey: ["resume", { username, slug }],
queryFn: () => findResumeByUsernameSlug({ username, slug }),
});
return resume;
} catch (error) {
toast({
variant: "error",
title: "The resume you were looking for was nowhere to be found... or maybe never existed?",
});
return redirect("/");
}
};

View File

@ -0,0 +1,55 @@
import { AwardsDialog } from "../pages/builder/sidebars/left/dialogs/awards";
import { CertificationsDialog } from "../pages/builder/sidebars/left/dialogs/certifications";
import { CustomSectionDialog } from "../pages/builder/sidebars/left/dialogs/custom-section";
import { EducationDialog } from "../pages/builder/sidebars/left/dialogs/education";
import { ExperienceDialog } from "../pages/builder/sidebars/left/dialogs/experience";
import { InterestsDialog } from "../pages/builder/sidebars/left/dialogs/interests";
import { LanguagesDialog } from "../pages/builder/sidebars/left/dialogs/languages";
import { ProfilesDialog } from "../pages/builder/sidebars/left/dialogs/profiles";
import { ProjectsDialog } from "../pages/builder/sidebars/left/dialogs/projects";
import { PublicationsDialog } from "../pages/builder/sidebars/left/dialogs/publications";
import { ReferencesDialog } from "../pages/builder/sidebars/left/dialogs/references";
import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills";
import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer";
import { ImportDialog } from "../pages/dashboard/resumes/_dialogs/import";
import { ResumeDialog } from "../pages/dashboard/resumes/_dialogs/resume";
import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor";
import { useResumeStore } from "../stores/resume";
type Props = {
children: React.ReactNode;
};
export const DialogProvider = ({ children }: Props) => {
const isResumeLoaded = useResumeStore((state) => Object.keys(state.resume).length > 0);
return (
<>
{children}
<div id="dialog-root">
<ResumeDialog />
<ImportDialog />
<TwoFactorDialog />
{isResumeLoaded && (
<>
<ProfilesDialog />
<ExperienceDialog />
<EducationDialog />
<AwardsDialog />
<CertificationsDialog />
<InterestsDialog />
<LanguagesDialog />
<ProjectsDialog />
<PublicationsDialog />
<VolunteerDialog />
<SkillsDialog />
<ReferencesDialog />
<CustomSectionDialog />
</>
)}
</div>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More