merge branch main

Signed-off-by: abizek <abishekilango@protonmail.com>
This commit is contained in:
abizek
2024-05-20 13:12:53 +05:30
301 changed files with 32052 additions and 26217 deletions

View File

@ -16,6 +16,18 @@
},
"plugins": ["lingui"],
"rules": {
// react
"react/no-unescaped-entities": "off",
"react/jsx-sort-props": [
"error",
{
"reservedFirst": true,
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": true
}
],
// react-hooks
"react-hooks/exhaustive-deps": "off",

View File

@ -1,10 +1,10 @@
const { join } = require("path");
const path = require("node:path");
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: { config: join(__dirname, "tailwind.config.js") },
tailwindcss: { config: path.join(__dirname, "tailwind.config.js") },
autoprefixer: {},
},
};

View File

@ -54,7 +54,7 @@ export const AiActions = ({ value, onChange, className }: Props) => {
toast({
variant: "error",
title: t`Oops, the server returned an error.`,
description: (error as Error)?.message,
description: (error as Error).message,
});
} finally {
setLoading(false);

View File

@ -27,10 +27,7 @@ export const Copyright = ({ className }: Props) => (
<span>{t`By the community, for the community.`}</span>
<span>
<Trans>
A passion project by{" "}
<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.amruthpillai.com/">
Amruth Pillai
</a>
A passion project by <a href="https://www.amruthpillai.com/">Amruth Pillai</a>
</Trans>
</span>

View File

@ -12,12 +12,14 @@ export const Icon = ({ size = 32, className }: Props) => {
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) {
case false:
case false: {
src = "/icon/dark.svg";
break;
case true:
}
case true: {
src = "/icon/light.svg";
break;
}
}
return (

View File

@ -38,8 +38,8 @@ export const LocaleCombobox = ({ value, onValueChange }: Props) => {
<Command shouldFilter={false}>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={t`Search for a language`}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>{t`No results found`}</CommandEmpty>
@ -48,10 +48,10 @@ export const LocaleCombobox = ({ value, onValueChange }: Props) => {
<div className="max-h-60">
{options.map(({ original }) => (
<CommandItem
disabled={false}
key={original.locale}
disabled={false}
value={original.locale.trim()}
onSelect={async (selectedValue) => {
onSelect={(selectedValue) => {
const result = options.find(
({ original }) => original.locale.trim() === selectedValue,
);

View File

@ -12,12 +12,14 @@ export const Logo = ({ size = 32, className }: Props) => {
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) {
case false:
case false: {
src = "/logo/light.svg";
break;
case true:
}
case true: {
src = "/logo/dark.svg";
break;
}
}
return (

View File

@ -12,9 +12,18 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
if (!user) return null;
let picture: React.ReactNode = null;
let picture: React.ReactNode;
if (!user.picture) {
if (user.picture) {
picture = (
<img
alt={user.name}
src={user.picture}
className="rounded-full"
style={{ width: size, height: size }}
/>
);
} else {
const initials = getInitials(user.name);
picture = (
@ -25,15 +34,6 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
{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

@ -24,7 +24,11 @@ export const UserOptions = ({ children }: Props) => {
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-48">
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")}>
<DropdownMenuItem
onClick={() => {
navigate("/dashboard/settings");
}}
>
{t`Settings`}
{/* eslint-disable-next-line lingui/no-unlocalized-strings */}
<KeyboardShortcut>S</KeyboardShortcut>

View File

@ -40,9 +40,9 @@ type Action =
toastId?: ToasterToast["id"];
};
interface State {
type State = {
toasts: ToasterToast[];
}
};
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@ -64,17 +64,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case "ADD_TOAST": {
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
}
case "UPDATE_TOAST":
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;
@ -82,9 +84,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
});
}
}
return {
@ -99,7 +101,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case "REMOVE_TOAST": {
if (action.toastId === undefined) {
return {
...state,
@ -110,18 +112,19 @@ export const reducer = (state: State, action: Action): State => {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
const listeners: Array<(state: State) => void> = [];
const listeners: ((state: State) => void)[] = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(memoryState);
});
}
}
type Toast = Omit<ToasterToast, "id">;
@ -129,12 +132,15 @@ type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = createId();
const update = (props: ToasterToast) =>
const update = (props: ToasterToast) => {
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
};
const dismiss = () => {
dispatch({ type: "DISMISS_TOAST", toastId: id });
};
dispatch({
type: "ADD_TOAST",
@ -170,7 +176,9 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) => {
dispatch({ type: "DISMISS_TOAST", toastId });
},
};
}

View File

@ -4,18 +4,13 @@ import _axios from "axios";
import createAuthRefreshInterceptor from "axios-auth-refresh";
import { redirect } from "react-router-dom";
import { refreshToken } from "@/client/services/auth";
import { USER_KEY } from "../constants/query-keys";
import { toast } from "../hooks/use-toast";
import { refresh } from "../services/auth/refresh";
import { translateError } from "../services/errors/translate-error";
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
@ -36,7 +31,7 @@ axios.interceptors.response.use(
});
}
return Promise.reject(error);
return Promise.reject(new Error(message));
},
);
@ -45,26 +40,12 @@ axios.interceptors.response.use(
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);
}
};
const handleAuthError = () => refreshToken(axiosForRefresh);
// 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);
}
await queryClient.invalidateQueries({ queryKey: USER_KEY });
redirect("/auth/login");
};
// Intercept responses to check for 401 and 403 errors, refresh token and retry the request

View File

@ -13,6 +13,7 @@ export async function dynamicActivate(locale: string) {
i18n.loadAndActivate({ locale, messages });
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (dayjsLocales[locale]) {
dayjs.locale(await dayjsLocales[locale]());
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,37 @@
import { StrictMode } from "react";
import * as Sentry from "@sentry/react";
import { StrictMode, useEffect } from "react";
import * as ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import {
createRoutesFromChildren,
matchRoutes,
RouterProvider,
useLocation,
useNavigationType,
} from "react-router-dom";
import { router } from "./router";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
if (import.meta.env.VITE_CLIENT_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_CLIENT_SENTRY_DSN,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
matchRoutes,
useLocation,
useNavigationType,
createRoutesFromChildren,
}),
Sentry.replayIntegration(),
],
tracesSampleRate: 1,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: 1,
});
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = ReactDOM.createRoot(document.querySelector("#root")!);
root.render(
<StrictMode>

View File

@ -40,7 +40,7 @@ export const BackupOtpPage = () => {
await backupOtp(data);
navigate("/dashboard");
} catch (error) {
} catch {
form.reset();
}
};
@ -87,7 +87,13 @@ export const BackupOtpPage = () => {
/>
<div className="mt-4 flex items-center gap-x-2">
<Button variant="link" className="px-5" onClick={() => navigate(-1)}>
<Button
variant="link"
className="px-5"
onClick={() => {
navigate(-1);
}}
>
<ArrowLeft size={14} className="mr-2" />
<span>{t`Back`}</span>
</Button>

View File

@ -89,7 +89,13 @@ export const ForgotPasswordPage = () => {
/>
<div className="mt-4 flex items-center gap-x-2">
<Button variant="link" className="px-5" onClick={() => navigate(-1)}>
<Button
variant="link"
className="px-5"
onClick={() => {
navigate(-1);
}}
>
<ArrowLeft size={14} className="mr-2" />
<span>{t`Back`}</span>
</Button>

View File

@ -30,7 +30,7 @@ export const LoginPage = () => {
const { login, loading } = useLogin();
const { providers } = useAuthProviders();
const emailAuthDisabled = !providers || !providers.includes("email");
const emailAuthDisabled = !providers?.includes("email");
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
@ -43,7 +43,7 @@ export const LoginPage = () => {
const onSubmit = async (data: FormValues) => {
try {
await login(data);
} catch (error) {
} catch {
form.reset();
}
};

View File

@ -34,7 +34,7 @@ export const RegisterPage = () => {
const disableSignups = import.meta.env.VITE_DISABLE_SIGNUPS === "true";
const { providers } = useAuthProviders();
const emailAuthDisabled = !providers || !providers.includes("email");
const emailAuthDisabled = !providers?.includes("email");
const formRef = useRef<HTMLFormElement>(null);
usePasswordToggle(formRef);
@ -55,7 +55,7 @@ export const RegisterPage = () => {
await register(data);
navigate("/auth/verify-email");
} catch (error) {
} catch {
form.reset();
}
};

View File

@ -26,7 +26,7 @@ type FormValues = z.infer<typeof resetPasswordSchema>;
export const ResetPasswordPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") || "";
const token = searchParams.get("token") ?? "";
const { resetPassword, loading } = useResetPassword();
@ -43,7 +43,7 @@ export const ResetPasswordPage = () => {
await resetPassword(data);
navigate("/auth/login");
} catch (error) {
} catch {
form.reset();
}
};

View File

@ -33,7 +33,7 @@ export const VerifyEmailPage = () => {
if (!token) return;
handleVerifyEmail(token);
void handleVerifyEmail(token);
}, [token, navigate, verifyEmail]);
return (

View File

@ -40,7 +40,7 @@ export const VerifyOtpPage = () => {
await verifyOtp(data);
navigate("/dashboard");
} catch (error) {
} catch {
form.reset();
}
};

View File

@ -18,7 +18,9 @@ export const BuilderHeader = () => {
const leftPanelSize = useBuilderStore((state) => state.panel.left.size);
const rightPanelSize = useBuilderStore((state) => state.panel.right.size);
const onToggle = (side: "left" | "right") => toggle(side);
const onToggle = (side: "left" | "right") => {
toggle(side);
};
return (
<div
@ -33,7 +35,9 @@ export const BuilderHeader = () => {
size="icon"
variant="ghost"
className="flex lg:hidden"
onClick={() => onToggle("left")}
onClick={() => {
onToggle("left");
}}
>
<SidebarSimple />
</Button>
@ -60,7 +64,9 @@ export const BuilderHeader = () => {
size="icon"
variant="ghost"
className="flex lg:hidden"
onClick={() => onToggle("right")}
onClick={() => {
onToggle("right");
}}
>
<SidebarSimple className="-scale-x-100" />
</Button>

View File

@ -20,6 +20,11 @@ import { usePrintResume } from "@/client/services/resume";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore, useTemporalResumeStore } from "@/client/stores/resume";
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
export const BuilderToolbar = () => {
const { toast } = useToast();
const setValue = useResumeStore((state) => state.setValue);
@ -36,11 +41,6 @@ export const BuilderToolbar = () => {
const onPrint = async () => {
const { url } = await printResume({ id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
@ -64,13 +64,27 @@ export const BuilderToolbar = () => {
<motion.div className="fixed inset-x-0 bottom-0 mx-auto hidden py-6 text-center md:block">
<div className="inline-flex items-center justify-center rounded-full bg-background px-4 shadow-xl">
<Tooltip content={t`Undo`}>
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => undo()}>
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => {
undo();
}}
>
<ArrowCounterClockwise />
</Button>
</Tooltip>
<Tooltip content={t`Redo`}>
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => redo()}>
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => {
redo();
}}
>
<ArrowClockwise />
</Button>
</Tooltip>
@ -134,8 +148,8 @@ export const BuilderToolbar = () => {
size="icon"
variant="ghost"
className="rounded-none"
onClick={onCopy}
disabled={!isPublic}
onClick={onCopy}
>
<LinkSimple />
</Button>
@ -145,9 +159,9 @@ export const BuilderToolbar = () => {
<Button
size="icon"
variant="ghost"
onClick={onPrint}
disabled={loading}
className="rounded-none"
onClick={onPrint}
>
{loading ? <CircleNotch className="animate-spin" /> : <FilePdf />}
</Button>

View File

@ -10,6 +10,10 @@ import { BuilderToolbar } from "./_components/toolbar";
import { LeftSidebar } from "./sidebars/left";
import { RightSidebar } from "./sidebars/right";
const onOpenAutoFocus = (event: Event) => {
event.preventDefault();
};
const OutletSlot = () => (
<>
<BuilderHeader />
@ -33,8 +37,6 @@ export const BuilderLayout = () => {
const leftHandle = useBuilderStore((state) => state.panel.left.handle);
const rightHandle = useBuilderStore((state) => state.panel.right.handle);
const onOpenAutoFocus = (event: Event) => event.preventDefault();
if (isDesktop) {
return (
<div className="relative size-full overflow-hidden">
@ -43,8 +45,8 @@ export const BuilderLayout = () => {
minSize={25}
maxSize={45}
defaultSize={30}
onResize={leftSetSize}
className={cn("z-10 bg-background", !leftHandle.isDragging && "transition-[flex]")}
onResize={leftSetSize}
>
<LeftSidebar />
</Panel>
@ -63,8 +65,8 @@ export const BuilderLayout = () => {
minSize={25}
maxSize={45}
defaultSize={30}
onResize={rightSetSize}
className={cn("z-10 bg-background", !rightHandle.isDragging && "transition-[flex]")}
onResize={rightSetSize}
>
<RightSidebar />
</Panel>
@ -79,8 +81,8 @@ export const BuilderLayout = () => {
<SheetContent
side="left"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
className="top-16 p-0 sm:max-w-xl"
onOpenAutoFocus={onOpenAutoFocus}
>
<LeftSidebar />
</SheetContent>
@ -92,8 +94,8 @@ export const BuilderLayout = () => {
<SheetContent
side="right"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
className="top-16 p-0 sm:max-w-xl"
onOpenAutoFocus={onOpenAutoFocus}
>
<RightSidebar />
</SheetContent>

View File

@ -17,16 +17,20 @@ export const BuilderPage = () => {
const title = useResumeStore((state) => state.resume.title);
const updateResumeInFrame = useCallback(() => {
if (!frameRef || !frameRef.contentWindow) return;
if (!frameRef?.contentWindow) return;
const message = { type: "SET_RESUME", payload: resume.data };
(() => frameRef.contentWindow.postMessage(message, "*"))();
(() => {
frameRef.contentWindow.postMessage(message, "*");
})();
}, [frameRef, resume.data]);
// Send resume data to iframe on initial load
useEffect(() => {
if (!frameRef) return;
frameRef.addEventListener("load", updateResumeInFrame);
return () => frameRef.removeEventListener("load", updateResumeInFrame);
return () => {
frameRef.removeEventListener("load", updateResumeInFrame);
};
}, [frameRef]);
// Send resume data to iframe on change of resume data
@ -53,7 +57,8 @@ export const BuilderPage = () => {
export const builderLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
try {
const id = params.id as string;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const id = params.id!;
const resume = await queryClient.fetchQuery({
queryKey: ["resume", { id }],
@ -64,7 +69,7 @@ export const builderLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
useResumeStore.temporal.getState().clear();
return resume;
} catch (error) {
} catch {
return redirect("/dashboard");
}
};

View File

@ -103,10 +103,12 @@ export const AwardsDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -97,10 +97,12 @@ export const CertificationsDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -20,7 +20,7 @@ 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 { useDialog } from "@/client/stores/dialog";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
@ -39,12 +39,13 @@ export const CustomSectionDialog = () => {
const [pendingKeyword, setPendingKeyword] = useState("");
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!payload) return null;
return (
<SectionDialog<FormValues>
form={form}
id={payload.id as DialogName}
id={payload.id}
defaultValues={defaultCustomSection}
pendingKeyword={pendingKeyword}
>
@ -129,10 +130,12 @@ export const CustomSectionDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
@ -160,8 +163,8 @@ export const CustomSectionDialog = () => {
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
layout
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}

View File

@ -140,10 +140,12 @@ export const EducationDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -116,10 +116,12 @@ export const ExperienceDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -74,8 +74,8 @@ export const InterestsDialog = () => {
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
layout
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}

View File

@ -69,7 +69,9 @@ export const LanguagesDialog = () => {
min={0}
max={5}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
onValueChange={(value) => {
field.onChange(value[0]);
}}
/>
{field.value === 0 ? (

View File

@ -110,10 +110,12 @@ export const ProjectsDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
@ -141,8 +143,8 @@ export const ProjectsDialog = () => {
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
layout
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}

View File

@ -97,10 +97,12 @@ export const PublicationsDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -83,10 +83,12 @@ export const ReferencesDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -83,7 +83,9 @@ export const SkillsDialog = () => {
max={5}
value={[field.value]}
orientation="horizontal"
onValueChange={(value) => field.onChange(value[0])}
onValueChange={(value) => {
field.onChange(value[0]);
}}
/>
{field.value === 0 ? (
@ -118,8 +120,8 @@ export const SkillsDialog = () => {
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
layout
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}

View File

@ -111,10 +111,12 @@ export const VolunteerDialog = () => {
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />

View File

@ -52,26 +52,93 @@ export const LeftSidebar = () => {
<div className="flex flex-col items-center justify-center gap-y-2">
<SectionIcon
id="basics"
onClick={() => scrollIntoView("#basics")}
name={t({
message: "Basics",
context:
"The basics section of a resume consists of User's Picture, Full Name, Location etc.",
})}
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="skills"
onClick={() => {
scrollIntoView("#skills");
}}
/>
<SectionIcon
id="languages"
onClick={() => {
scrollIntoView("#languages");
}}
/>
<SectionIcon
id="awards"
onClick={() => {
scrollIntoView("#awards");
}}
/>
<SectionIcon
id="certifications"
onClick={() => {
scrollIntoView("#certifications");
}}
/>
<SectionIcon
id="interests"
onClick={() => {
scrollIntoView("#interests");
}}
/>
<SectionIcon
id="projects"
onClick={() => {
scrollIntoView("#projects");
}}
/>
<SectionIcon
id="publications"
onClick={() => {
scrollIntoView("#publications");
}}
/>
<SectionIcon
id="volunteer"
onClick={() => {
scrollIntoView("#volunteer");
}}
/>
<SectionIcon
id="references"
onClick={() => {
scrollIntoView("#references");
}}
/>
<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="skills" onClick={() => scrollIntoView("#skills")} />
<SectionIcon id="languages" onClick={() => scrollIntoView("#languages")} />
<SectionIcon id="awards" onClick={() => scrollIntoView("#awards")} />
<SectionIcon id="certifications" onClick={() => scrollIntoView("#certifications")} />
<SectionIcon id="interests" onClick={() => scrollIntoView("#interests")} />
<SectionIcon id="projects" onClick={() => scrollIntoView("#projects")} />
<SectionIcon id="publications" onClick={() => scrollIntoView("#publications")} />
<SectionIcon id="volunteer" onClick={() => scrollIntoView("#volunteer")} />
<SectionIcon id="references" onClick={() => scrollIntoView("#references")} />
<SectionIcon
id="custom"

View File

@ -33,7 +33,9 @@ export const BasicsSection = () => {
id="basics.name"
value={basics.name}
hasError={!basicsSchema.pick({ name: true }).safeParse({ name: basics.name }).success}
onChange={(event) => setValue("basics.name", event.target.value)}
onChange={(event) => {
setValue("basics.name", event.target.value);
}}
/>
</div>
@ -42,7 +44,9 @@ export const BasicsSection = () => {
<Input
id="basics.headline"
value={basics.headline}
onChange={(event) => setValue("basics.headline", event.target.value)}
onChange={(event) => {
setValue("basics.headline", event.target.value);
}}
/>
</div>
@ -55,7 +59,9 @@ export const BasicsSection = () => {
hasError={
!basicsSchema.pick({ email: true }).safeParse({ email: basics.email }).success
}
onChange={(event) => setValue("basics.email", event.target.value)}
onChange={(event) => {
setValue("basics.email", event.target.value);
}}
/>
</div>
@ -65,7 +71,9 @@ export const BasicsSection = () => {
id="basics.url"
value={basics.url}
placeholder="https://example.com"
onChange={(value) => setValue("basics.url", value)}
onChange={(value) => {
setValue("basics.url", value);
}}
/>
</div>
@ -75,7 +83,9 @@ export const BasicsSection = () => {
id="basics.phone"
placeholder="+1 (123) 4567 7890"
value={basics.phone}
onChange={(event) => setValue("basics.phone", event.target.value)}
onChange={(event) => {
setValue("basics.phone", event.target.value);
}}
/>
</div>
@ -84,7 +94,9 @@ export const BasicsSection = () => {
<Input
id="basics.location"
value={basics.location}
onChange={(event) => setValue("basics.location", event.target.value)}
onChange={(event) => {
setValue("basics.location", event.target.value);
}}
/>
</div>

View File

@ -17,8 +17,9 @@ type CustomFieldProps = {
export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) => {
const controls = useDragControls();
const handleChange = (key: "icon" | "name" | "value", value: string) =>
const handleChange = (key: "icon" | "name" | "value", value: string) => {
onChange({ ...field, [key]: value });
};
return (
<Reorder.Item
@ -34,7 +35,9 @@ export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) =>
size="icon"
variant="link"
className="shrink-0"
onPointerDown={(event) => controls.start(event)}
onPointerDown={(event) => {
controls.start(event);
}}
>
<DotsSixVertical />
</Button>
@ -43,20 +46,26 @@ export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) =>
placeholder={t`Name`}
value={field.name}
className="!ml-0"
onChange={(event) => handleChange("name", event.target.value)}
onChange={(event) => {
handleChange("name", event.target.value);
}}
/>
<Input
placeholder={t`Value`}
value={field.value}
onChange={(event) => handleChange("value", event.target.value)}
onChange={(event) => {
handleChange("value", event.target.value);
}}
/>
<Button
size="icon"
variant="link"
className="!ml-0 shrink-0"
onClick={() => onRemove(field.id)}
onClick={() => {
onRemove(field.id);
}}
>
<X />
</Button>
@ -82,7 +91,7 @@ export const CustomFieldsSection = ({ className }: Props) => {
const onChangeCustomField = (field: ICustomField) => {
const index = customFields.findIndex((item) => item.id === field.id);
const newCustomFields = JSON.parse(JSON.stringify(customFields)) as ICustomField[];
const newCustomFields = JSON.parse(JSON.stringify(customFields));
newCustomFields[index] = field;
setValue("basics.customFields", newCustomFields);
@ -110,8 +119,8 @@ export const CustomFieldsSection = ({ className }: Props) => {
>
{customFields.map((field) => (
<CustomField
field={field}
key={field.id}
field={field}
onChange={onChangeCustomField}
onRemove={onRemoveCustomField}
/>

View File

@ -47,22 +47,20 @@ export const PictureOptions = () => {
const picture = useResumeStore((state) => state.resume.data.basics.picture);
const aspectRatio = useMemo(() => {
const ratio = picture.aspectRatio?.toString() as keyof typeof ratioToStringMap;
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;
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]);
};
@ -88,8 +86,8 @@ export const PictureOptions = () => {
<ToggleGroup
type="single"
value={aspectRatio}
onValueChange={onAspectRatioChange}
className="flex items-center justify-center"
onValueChange={onAspectRatioChange}
>
<Tooltip content={t`Square`}>
<ToggleGroupItem value="square">
@ -119,7 +117,7 @@ export const PictureOptions = () => {
id="picture.aspectRatio"
value={picture.aspectRatio}
onChange={(event) => {
setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0);
setValue("basics.picture.aspectRatio", event.target.valueAsNumber);
}}
/>
</div>
@ -131,8 +129,8 @@ export const PictureOptions = () => {
<ToggleGroup
type="single"
value={borderRadius}
onValueChange={onBorderRadiusChange}
className="flex items-center justify-center"
onValueChange={onBorderRadiusChange}
>
<Tooltip content={t`Square`}>
<ToggleGroupItem value="square">
@ -162,7 +160,7 @@ export const PictureOptions = () => {
id="picture.borderRadius"
value={picture.borderRadius}
onChange={(event) => {
setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0);
setValue("basics.picture.borderRadius", event.target.valueAsNumber);
}}
/>
</div>

View File

@ -68,13 +68,15 @@ export const PictureSection = () => {
<div className="flex w-full flex-col gap-y-1.5">
<Label htmlFor="basics.picture.url">{t`Picture`}</Label>
<div className="flex items-center gap-x-2">
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
<input ref={inputRef} hidden type="file" onChange={onSelectImage} />
<Input
id="basics.picture.url"
placeholder="https://..."
value={picture.url}
onChange={(event) => setValue("basics.picture.url", event.target.value)}
onChange={(event) => {
setValue("basics.picture.url", event.target.value);
}}
/>
{isValidUrl && (

View File

@ -50,6 +50,7 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
}),
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!section) return null;
const onDragEnd = (event: DragEndEvent) => {
@ -66,10 +67,18 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
}
};
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 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);
@ -100,8 +109,8 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
{section.items.length === 0 && (
<Button
variant="outline"
onClick={onCreate}
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
onClick={onCreate}
>
<Plus size={14} />
<span className="font-medium">
@ -115,23 +124,31 @@ export const SectionBase = <T extends SectionItem>({ id, title, description }: P
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
collisionDetection={closestCenter}
modifiers={[restrictToParentElement]}
onDragEnd={onDragEnd}
>
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
<AnimatePresence>
{section.items.map((item, index) => (
<SectionListItem
id={item.id}
key={item.id}
id={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)}
onUpdate={() => {
onUpdate(item as T);
}}
onDelete={() => {
onDelete(item as T);
}}
onDuplicate={() => {
onDuplicate(item as T);
}}
onToggleVisibility={() => {
onToggleVisibility(index);
}}
/>
))}
</AnimatePresence>

View File

@ -46,7 +46,6 @@ export const SectionDialog = <T extends SectionItem>({
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;
@ -59,7 +58,7 @@ export const SectionDialog = <T extends SectionItem>({
if (isOpen) onReset();
}, [isOpen, payload]);
const onSubmit = async (values: T) => {
const onSubmit = (values: T) => {
if (!section) return;
if (isCreate || isDuplicate) {

View File

@ -24,37 +24,52 @@ import { useResumeStore } from "@/client/stores/resume";
export const getSectionIcon = (id: SectionKey, props: IconProps = {}) => {
switch (id) {
// Left Sidebar
case "basics":
case "basics": {
return <User size={18} {...props} />;
case "summary":
}
case "summary": {
return <Article size={18} {...props} />;
case "awards":
}
case "awards": {
return <Medal size={18} {...props} />;
case "profiles":
}
case "profiles": {
return <ShareNetwork size={18} {...props} />;
case "experience":
}
case "experience": {
return <Briefcase size={18} {...props} />;
case "education":
}
case "education": {
return <GraduationCap size={18} {...props} />;
case "certifications":
}
case "certifications": {
return <Certificate size={18} {...props} />;
case "interests":
}
case "interests": {
return <GameController size={18} {...props} />;
case "languages":
}
case "languages": {
return <Translate size={18} {...props} />;
case "volunteer":
}
case "volunteer": {
return <HandHeart size={18} {...props} />;
case "projects":
}
case "projects": {
return <PuzzlePiece size={18} {...props} />;
case "publications":
}
case "publications": {
return <Books size={18} {...props} />;
case "skills":
}
case "skills": {
return <CompassTool size={18} {...props} />;
case "references":
}
case "references": {
return <Users size={18} {...props} />;
}
default:
default: {
return null;
}
}
};

View File

@ -71,11 +71,11 @@ export const SectionListItem = ({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
onClick={onUpdate}
className={cn(
"flex-1 cursor-context-menu p-4 hover:bg-secondary-accent",
!visible && "opacity-50",
)}
onClick={onUpdate}
>
<h4 className="font-medium leading-relaxed">{title}</h4>
{description && <p className="text-xs leading-relaxed opacity-50">{description}</p>}

View File

@ -46,12 +46,24 @@ export const SectionOptions = ({ id }: Props) => {
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);
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>
@ -94,8 +106,8 @@ export const SectionOptions = ({ id }: Props) => {
<Button
size="icon"
variant="link"
onClick={onResetName}
className="absolute inset-y-0 right-0"
onClick={onResetName}
>
<ArrowCounterClockwise />
</Button>

View File

@ -11,28 +11,30 @@ import {
} from "@reactive-resume/ui";
import { forwardRef, useMemo } from "react";
interface Props {
type 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]);
const hasError = useMemo(() => !urlSchema.safeParse(value).success, [value]);
return (
<>
<div className="flex gap-x-1">
<Input
id={id}
ref={ref}
id={id}
value={value.href}
className="flex-1"
hasError={hasError}
placeholder={placeholder}
onChange={(event) => onChange({ ...value, href: event.target.value })}
onChange={(event) => {
onChange({ ...value, href: event.target.value });
}}
/>
<Popover>
@ -47,7 +49,9 @@ export const URLInput = forwardRef<HTMLInputElement, Props>(
<Input
value={value.label}
placeholder={t`Label`}
onChange={(event) => onChange({ ...value, label: event.target.value })}
onChange={(event) => {
onChange({ ...value, label: event.target.value });
}}
/>
</PopoverContent>
</Popover>

View File

@ -11,6 +11,7 @@ import { SectionOptions } from "./shared/section-options";
export const SummarySection = () => {
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(state) => state.resume.data.sections.summary ?? defaultSections.summary,
);
@ -30,10 +31,12 @@ export const SummarySection = () => {
<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} />
)}
onChange={(value) => {
setValue("sections.summary.content", value);
}}
/>
</main>
</section>

View File

@ -60,28 +60,72 @@ export const RightSidebar = () => {
<SectionIcon
id="template"
name={t`Template`}
onClick={() => scrollIntoView("#template")}
onClick={() => {
scrollIntoView("#template");
}}
/>
<SectionIcon
id="layout"
name={t`Layout`}
onClick={() => {
scrollIntoView("#layout");
}}
/>
<SectionIcon id="layout" name={t`Layout`} onClick={() => scrollIntoView("#layout")} />
<SectionIcon
id="typography"
name={t`Typography`}
onClick={() => scrollIntoView("#typography")}
onClick={() => {
scrollIntoView("#typography");
}}
/>
<SectionIcon
id="theme"
name={t`Theme`}
onClick={() => {
scrollIntoView("#theme");
}}
/>
<SectionIcon
id="page"
name={t`Page`}
onClick={() => {
scrollIntoView("#page");
}}
/>
<SectionIcon
id="sharing"
name={t`Sharing`}
onClick={() => {
scrollIntoView("#sharing");
}}
/>
<SectionIcon id="theme" name={t`Theme`} onClick={() => scrollIntoView("#theme")} />
<SectionIcon id="page" name={t`Page`} onClick={() => scrollIntoView("#page")} />
<SectionIcon id="sharing" name={t`Sharing`} onClick={() => scrollIntoView("#sharing")} />
<SectionIcon
id="statistics"
name={t`Statistics`}
onClick={() => scrollIntoView("#statistics")}
onClick={() => {
scrollIntoView("#statistics");
}}
/>
<SectionIcon
id="export"
name={t`Export`}
onClick={() => {
scrollIntoView("#export");
}}
/>
<SectionIcon
id="notes"
name={t`Notes`}
onClick={() => {
scrollIntoView("#notes");
}}
/>
<SectionIcon id="export" name={t`Export`} onClick={() => scrollIntoView("#export")} />
<SectionIcon id="notes" name={t`Notes`} onClick={() => scrollIntoView("#notes")} />
<SectionIcon
id="information"
name={t`Information`}
onClick={() => scrollIntoView("#information")}
onClick={() => {
scrollIntoView("#information");
}}
/>
</div>

View File

@ -9,26 +9,26 @@ import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
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);
};
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
export const ExportSection = () => {
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);
};
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);
};
@ -43,11 +43,11 @@ export const ExportSection = () => {
<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",
)}
onClick={onJsonExport}
>
<FileJs size={22} />
<CardContent className="flex-1">
@ -59,12 +59,12 @@ export const ExportSection = () => {
</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",
)}
onClick={onPdfExport}
>
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}

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