mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-12 15:52:56 +10:00
Merge branch 'main' into feat/separate-links
This commit is contained in:
@ -11,11 +11,24 @@
|
||||
"settings": {
|
||||
"tailwindcss": {
|
||||
"callees": ["cn", "clsx", "cva"],
|
||||
"config": "tailwind.config.js"
|
||||
"config": "tailwind.config.js",
|
||||
"whitelist": ["ph", "ph-"]
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
|
||||
@ -35,10 +35,13 @@
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/src/styles/main.css" />
|
||||
</head>
|
||||
<body class="bg-background text-foreground text-sm antialiased print:bg-white print:m-0">
|
||||
<body class="text-sm antialiased bg-background text-foreground print:bg-white print:m-0">
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
<!-- Phosphor Icons -->
|
||||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
BIN
apps/client/public/assets/europass.png
Normal file
BIN
apps/client/public/assets/europass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@ -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
1703
apps/client/src/locales/ms-MY/messages.po
Normal file
1703
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
1703
apps/client/src/locales/sq-AL/messages.po
Normal file
1703
apps/client/src/locales/sq-AL/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
@ -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 ? (
|
||||
|
||||
@ -12,7 +12,9 @@ import {
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SectionDialog } from "../sections/shared/section-dialog";
|
||||
@ -28,6 +30,16 @@ export const ProfilesDialog = () => {
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const [iconSrc, setIconSrc] = useDebounceValue("", 400);
|
||||
|
||||
const handleIconChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.value === "") {
|
||||
setIconSrc("");
|
||||
} else {
|
||||
setIconSrc(`https://cdn.simpleicons.org/${event.target.value}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SectionDialog<FormValues> id="profiles" form={form} defaultValues={defaultProfile}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@ -83,14 +95,17 @@ export const ProfilesDialog = () => {
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Avatar className="size-8 bg-white">
|
||||
{field.value && (
|
||||
<AvatarImage
|
||||
className="p-1.5"
|
||||
src={`https://cdn.simpleicons.org/${field.value}`}
|
||||
/>
|
||||
)}
|
||||
{iconSrc && <AvatarImage className="p-1.5" src={iconSrc} />}
|
||||
</Avatar>
|
||||
<Input {...field} id="iconSlug" placeholder="linkedin" />
|
||||
<Input
|
||||
{...field}
|
||||
id="iconSlug"
|
||||
placeholder="linkedin"
|
||||
onChange={(event) => {
|
||||
field.onChange(event);
|
||||
handleIconChange(event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { DotsSixVertical, Plus, X } from "@phosphor-icons/react";
|
||||
import { DotsSixVertical, Envelope, Plus, X } from "@phosphor-icons/react";
|
||||
import { CustomField as ICustomField } from "@reactive-resume/schema";
|
||||
import { Button, Input } from "@reactive-resume/ui";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, Reorder, useDragControls } from "framer-motion";
|
||||
|
||||
@ -17,8 +24,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,29 +42,56 @@ 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>
|
||||
|
||||
<Popover>
|
||||
<Tooltip content={t`Icon`}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
{field.icon ? <i className={cn(`ph ph-${field.icon}`)} /> : <Envelope />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</Tooltip>
|
||||
<PopoverContent className="p-1.5">
|
||||
<Input
|
||||
value={field.icon}
|
||||
placeholder={t`Enter Phosphor Icon`}
|
||||
onChange={(event) => {
|
||||
onChange({ ...field, icon: event.target.value });
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Input
|
||||
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 +117,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 +145,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>}
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
Plus,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { defaultSections, SectionKey, sectionsSchema, SectionWithItem } from "@reactive-resume/schema";
|
||||
import { defaultSections, SectionKey, SectionWithItem } from "@reactive-resume/schema";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@ -32,9 +32,6 @@ import { useMemo } from "react";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
import { DropdownMenuItemIndicator } from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckboxIndicator } from "@radix-ui/react-checkbox";
|
||||
import { itemsEqual } from "@dnd-kit/sortable/dist/utilities";
|
||||
|
||||
type Props = { id: SectionKey };
|
||||
|
||||
@ -50,13 +47,33 @@ 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 toggleSeperateLinks = (checked: boolean) => setValue(`sections.${id}.separateLinks`, checked);
|
||||
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 toggleSeperateLinks = (checked: boolean) => {
|
||||
setValue(`sections.${id}.separateLinks`, checked);
|
||||
};
|
||||
|
||||
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>
|
||||
@ -73,8 +90,8 @@ export const SectionOptions = ({ id }: Props) => {
|
||||
<span className="ml-2">{t`Add a new item`}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
onCheckedChange={toggleSeperateLinks}
|
||||
checked={section.separateLinks}
|
||||
onCheckedChange={toggleSeperateLinks}
|
||||
>
|
||||
<span className="ml-0">{t`Separate Links`}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
@ -104,8 +121,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user