mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-12 15:52:56 +10:00
@ -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",
|
||||
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
1698
apps/client/src/locales/ms-MY/messages.po
Normal file
1698
apps/client/src/locales/ms-MY/messages.po
Normal file
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
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -33,7 +33,7 @@ export const VerifyEmailPage = () => {
|
||||
|
||||
if (!token) return;
|
||||
|
||||
handleVerifyEmail(token);
|
||||
void handleVerifyEmail(token);
|
||||
}, [token, navigate, verifyEmail]);
|
||||
|
||||
return (
|
||||
|
||||
@ -40,7 +40,7 @@ export const VerifyOtpPage = () => {
|
||||
await verifyOtp(data);
|
||||
|
||||
navigate("/dashboard");
|
||||
} catch (error) {
|
||||
} catch {
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
Reference in New Issue
Block a user