mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-18 18:51:32 +10:00
Merge branch 'main' into custom-icons-in-header
This commit is contained in:
@ -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>
|
||||
|
||||
|
||||
@ -24,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
|
||||
@ -41,7 +42,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>
|
||||
@ -66,20 +69,27 @@ export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) =>
|
||||
<Input
|
||||
placeholder={t`Name`}
|
||||
value={field.name}
|
||||
onChange={(event) => handleChange("name", event.target.value)}
|
||||
className="!ml-0"
|
||||
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>
|
||||
@ -105,7 +115,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);
|
||||
@ -133,8 +143,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} />}
|
||||
|
||||
|
||||
@ -69,12 +69,7 @@ const IssuesCard = () => (
|
||||
<span className="line-clamp-1">{t`Raise an issue`}</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
href="mailto:hello@amruthpillai.com"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<a className={cn(buttonVariants({ size: "sm" }))} href="mailto:hello@amruthpillai.com">
|
||||
<EnvelopeSimpleOpen size={14} weight="bold" className="mr-2" />
|
||||
<span className="line-clamp-1">{t`Send me a message`}</span>
|
||||
</a>
|
||||
|
||||
@ -163,7 +163,7 @@ export const LayoutSection = () => {
|
||||
};
|
||||
|
||||
const onAddPage = () => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout));
|
||||
|
||||
layoutCopy.push([[], []]);
|
||||
|
||||
@ -171,7 +171,7 @@ export const LayoutSection = () => {
|
||||
};
|
||||
|
||||
const onRemovePage = (page: number) => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
|
||||
const layoutCopy = JSON.parse(JSON.stringify(layout));
|
||||
|
||||
layoutCopy[0][0].push(...layoutCopy[page][0]); // Main
|
||||
layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar
|
||||
@ -182,17 +182,17 @@ export const LayoutSection = () => {
|
||||
};
|
||||
|
||||
const onResetLayout = () => {
|
||||
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][];
|
||||
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout));
|
||||
|
||||
// Loop through all pages and columns, and get any sections that start with "custom."
|
||||
// These should be appended to the first page of the new layout.
|
||||
const customSections: string[] = [];
|
||||
|
||||
layout.forEach((page) => {
|
||||
page.forEach((column) => {
|
||||
for (const page of layout) {
|
||||
for (const column of page) {
|
||||
customSections.push(...column.filter((section) => section.startsWith("custom.")));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (customSections.length > 0) layoutCopy[0][0].push(...customSections);
|
||||
|
||||
@ -218,10 +218,10 @@ export const LayoutSection = () => {
|
||||
{/* Pages */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
{layout.map((page, pageIndex) => {
|
||||
const mainIndex = `${pageIndex}.0`;
|
||||
@ -243,7 +243,9 @@ export const LayoutSection = () => {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
onClick={() => onRemovePage(pageIndex)}
|
||||
onClick={() => {
|
||||
onRemovePage(pageIndex);
|
||||
}}
|
||||
>
|
||||
<TrashSimple size={12} className="text-error" />
|
||||
</Button>
|
||||
@ -260,7 +262,7 @@ export const LayoutSection = () => {
|
||||
})}
|
||||
|
||||
<Portal>
|
||||
<DragOverlay>{activeId && <Section id={activeId} isDragging />}</DragOverlay>
|
||||
<DragOverlay>{activeId && <Section isDragging id={activeId} />}</DragOverlay>
|
||||
</Portal>
|
||||
</DndContext>
|
||||
|
||||
|
||||
@ -24,7 +24,12 @@ export const NotesSection = () => {
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<RichInput content={notes} onChange={(content) => setValue("metadata.notes", content)} />
|
||||
<RichInput
|
||||
content={notes}
|
||||
onChange={(content) => {
|
||||
setValue("metadata.notes", content);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-xs leading-relaxed opacity-75">
|
||||
{t`For example, information regarding which companies you sent this resume to or the links to the job descriptions can be noted down here.`}
|
||||
|
||||
@ -19,7 +19,7 @@ export const PageSection = () => {
|
||||
const page = useResumeStore((state) => state.resume.data.metadata.page);
|
||||
|
||||
return (
|
||||
<section id="theme" className="grid gap-y-6">
|
||||
<section id="page" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("page")}
|
||||
|
||||
@ -73,7 +73,7 @@ export const SharingSection = () => {
|
||||
<Label htmlFor="resume-url">{t`URL`}</Label>
|
||||
|
||||
<div className="flex gap-x-1.5">
|
||||
<Input id="resume-url" readOnly value={url} className="flex-1" />
|
||||
<Input readOnly id="resume-url" value={url} className="flex-1" />
|
||||
|
||||
<Tooltip content={t`Copy to Clipboard`}>
|
||||
<Button size="icon" variant="ghost" onClick={onCopy}>
|
||||
|
||||
@ -27,11 +27,13 @@ export const TemplateSection = () => {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: index * 0.1 } }}
|
||||
whileTap={{ scale: 0.98, transition: { duration: 0.1 } }}
|
||||
onClick={() => setValue("metadata.template", template)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-sm ring-primary transition-all hover:ring-2",
|
||||
currentTemplate === template && "ring-2",
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue("metadata.template", template);
|
||||
}}
|
||||
>
|
||||
<img src={`/templates/jpg/${template}.jpg`} alt={template} className="rounded-sm" />
|
||||
|
||||
|
||||
@ -26,13 +26,13 @@ export const ThemeSection = () => {
|
||||
{colors.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
onClick={() => {
|
||||
setValue("metadata.theme.primary", color);
|
||||
}}
|
||||
className={cn(
|
||||
"flex size-6 cursor-pointer items-center justify-center rounded-full ring-primary ring-offset-1 ring-offset-background transition-shadow hover:ring-1",
|
||||
theme.primary === color && "ring-1",
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue("metadata.theme.primary", color);
|
||||
}}
|
||||
>
|
||||
<div className="size-5 rounded-full" style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
@ -79,7 +79,7 @@ export const ThemeSection = () => {
|
||||
style={{ backgroundColor: theme.background }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
|
||||
<PopoverContent className="rounded-lg border-none bg-transparent p-0">
|
||||
<HexColorPicker
|
||||
color={theme.background}
|
||||
onChange={(color) => {
|
||||
@ -109,7 +109,7 @@ export const ThemeSection = () => {
|
||||
style={{ backgroundColor: theme.text }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
|
||||
<PopoverContent className="rounded-lg border-none bg-transparent p-0">
|
||||
<HexColorPicker
|
||||
color={theme.text}
|
||||
onChange={(color) => {
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { fonts } from "@reactive-resume/utils";
|
||||
import { cn, fonts } from "@reactive-resume/utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import webfontloader from "webfontloader";
|
||||
|
||||
@ -36,14 +35,14 @@ export const TypographySection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
|
||||
|
||||
const loadFontSuggestions = useCallback(async () => {
|
||||
fontSuggestions.forEach((font) => {
|
||||
const loadFontSuggestions = useCallback(() => {
|
||||
for (const font of fontSuggestions) {
|
||||
webfontloader.load({
|
||||
events: false,
|
||||
classes: false,
|
||||
google: { families: [font], text: font },
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [fontSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -75,15 +74,15 @@ export const TypographySection = () => {
|
||||
variant="outline"
|
||||
style={{ fontFamily: font }}
|
||||
disabled={typography.font.family === font}
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
|
||||
typography.font.family === font && "ring-1",
|
||||
)}
|
||||
onClick={() => {
|
||||
setValue("metadata.typography.font.family", font);
|
||||
setValue("metadata.typography.font.subset", "latin");
|
||||
setValue("metadata.typography.font.variants", ["regular"]);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
|
||||
typography.font.family === font && "ring-1",
|
||||
)}
|
||||
>
|
||||
{font}
|
||||
</Button>
|
||||
|
||||
@ -30,31 +30,43 @@ export type MetadataKey =
|
||||
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
|
||||
switch (id) {
|
||||
// Left Sidebar
|
||||
case "notes":
|
||||
case "notes": {
|
||||
return <Note size={18} {...props} />;
|
||||
case "template":
|
||||
}
|
||||
case "template": {
|
||||
return <DiamondsFour size={18} {...props} />;
|
||||
case "layout":
|
||||
}
|
||||
case "layout": {
|
||||
return <Layout size={18} {...props} />;
|
||||
case "typography":
|
||||
}
|
||||
case "typography": {
|
||||
return <TextT size={18} {...props} />;
|
||||
case "theme":
|
||||
}
|
||||
case "theme": {
|
||||
return <Palette size={18} {...props} />;
|
||||
case "page":
|
||||
}
|
||||
case "page": {
|
||||
return <ReadCvLogo size={18} {...props} />;
|
||||
case "locale":
|
||||
}
|
||||
case "locale": {
|
||||
return <Translate size={18} {...props} />;
|
||||
case "sharing":
|
||||
}
|
||||
case "sharing": {
|
||||
return <ShareFat size={18} {...props} />;
|
||||
case "statistics":
|
||||
}
|
||||
case "statistics": {
|
||||
return <TrendUp size={18} {...props} />;
|
||||
case "export":
|
||||
}
|
||||
case "export": {
|
||||
return <DownloadSimple size={18} {...props} />;
|
||||
case "information":
|
||||
}
|
||||
case "information": {
|
||||
return <Info size={18} {...props} />;
|
||||
}
|
||||
|
||||
default:
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -27,12 +27,12 @@ const ActiveIndicator = ({ className }: Props) => (
|
||||
/>
|
||||
);
|
||||
|
||||
interface SidebarItem {
|
||||
type SidebarItem = {
|
||||
path: string;
|
||||
name: string;
|
||||
shortcut?: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
type SidebarItemProps = SidebarItem & {
|
||||
onClick?: () => void;
|
||||
@ -46,11 +46,11 @@ const SidebarItem = ({ path, name, shortcut, icon, onClick }: SidebarItemProps)
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-auto justify-start px-4 py-3",
|
||||
isActive && "pointer-events-none bg-secondary/50 text-secondary-foreground",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Link to={path}>
|
||||
<div className="mr-3">{icon}</div>
|
||||
|
||||
@ -72,11 +72,14 @@ type ValidationResult =
|
||||
export const ImportDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, close } = useDialog("import");
|
||||
const { importResume, loading, error: importError } = useImportResume();
|
||||
const { importResume, loading } = useImportResume();
|
||||
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
type: ImportType["reactive-resume-json"],
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const filetype = form.watch("type");
|
||||
@ -91,7 +94,6 @@ export const ImportDialog = () => {
|
||||
}, [filetype]);
|
||||
|
||||
const accept = useMemo(() => {
|
||||
if (!filetype) return "";
|
||||
if (filetype.includes("json")) return ".json";
|
||||
if (filetype.includes("zip")) return ".zip";
|
||||
return "";
|
||||
@ -182,11 +184,11 @@ export const ImportDialog = () => {
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: t`Oops, the server returned an error.`,
|
||||
description: importError?.message,
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -255,11 +257,11 @@ export const ImportDialog = () => {
|
||||
<FormLabel>{t`File`}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
key={`${accept}-${filetype}`}
|
||||
type="file"
|
||||
key={accept}
|
||||
accept={accept}
|
||||
onChange={(event) => {
|
||||
if (!event.target.files || !event.target.files.length) return;
|
||||
if (!event.target.files?.length) return;
|
||||
field.onChange(event.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
@ -278,7 +280,7 @@ export const ImportDialog = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationResult?.isValid === false && validationResult.errors !== undefined && (
|
||||
{validationResult?.isValid === false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-error">{t`Errors`}</Label>
|
||||
<ScrollArea orientation="vertical" className="h-[180px]">
|
||||
@ -291,7 +293,7 @@ export const ImportDialog = () => {
|
||||
|
||||
<DialogFooter>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{(!validationResult ?? false) && (
|
||||
{!validationResult && (
|
||||
<Button type="button" onClick={onValidate}>
|
||||
{t`Validate`}
|
||||
</Button>
|
||||
@ -305,7 +307,7 @@ export const ImportDialog = () => {
|
||||
|
||||
{validationResult !== null && validationResult.isValid && (
|
||||
<>
|
||||
<Button type="button" onClick={onImport} disabled={loading}>
|
||||
<Button type="button" disabled={loading} onClick={onImport}>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
|
||||
|
||||
@ -103,7 +103,7 @@ export const ResumeDialog = () => {
|
||||
if (isDelete) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await deleteResume({ id: payload.item?.id });
|
||||
await deleteResume({ id: payload.item.id });
|
||||
}
|
||||
|
||||
close();
|
||||
@ -131,7 +131,7 @@ export const ResumeDialog = () => {
|
||||
|
||||
await duplicateResume({
|
||||
title: title || randomName,
|
||||
slug: slug || kebabCase(randomName),
|
||||
slug: slug ?? kebabCase(randomName),
|
||||
data: sampleResume,
|
||||
});
|
||||
|
||||
|
||||
@ -13,11 +13,11 @@ type Props = {
|
||||
export const BaseCard = ({ children, className, onClick }: Props) => (
|
||||
<Tilt {...defaultTiltProps}>
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex aspect-[1/1.4142] scale-100 cursor-pointer items-center justify-center bg-secondary/50 p-0 transition-transform active:scale-95",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
|
||||
@ -11,7 +11,11 @@ export const CreateResumeCard = () => {
|
||||
const { open } = useDialog("resume");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<BaseCard
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
>
|
||||
<Plus size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
|
||||
@ -11,7 +11,11 @@ export const ImportResumeCard = () => {
|
||||
const { open } = useDialog("import");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<BaseCard
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
>
|
||||
<DownloadSimple size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
|
||||
@ -62,7 +62,7 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<BaseCard onClick={onOpen} className="space-y-0">
|
||||
<BaseCard className="space-y-0" onClick={onOpen}>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{loading && (
|
||||
<motion.div
|
||||
@ -88,7 +88,7 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="size-full object-cover"
|
||||
src={`${url}?cache=${new Date().getTime()}`}
|
||||
src={`${url}?cache=${Date.now()}`}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@ -143,7 +143,7 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<ContextMenuItem className="text-error" onClick={onDelete}>
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
{t`Delete`}
|
||||
</ContextMenuItem>
|
||||
|
||||
@ -25,7 +25,7 @@ export const GridView = () => {
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
@ -41,8 +41,8 @@ export const GridView = () => {
|
||||
.sort((a, b) => sortByDate(a, b, "updatedAt"))
|
||||
.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: (index + 2) * 0.1 } }}
|
||||
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
|
||||
|
||||
@ -11,11 +11,11 @@ type Props = {
|
||||
|
||||
export const BaseListItem = ({ title, description, start, end, className, onClick }: Props) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center rounded p-4 transition-colors hover:bg-secondary/30",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@ -13,7 +13,6 @@ export const CreateResumeListItem = () => {
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<Plus size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>{t`Create a new resume`}</span>
|
||||
@ -22,6 +21,9 @@ export const CreateResumeListItem = () => {
|
||||
</>
|
||||
}
|
||||
description={t`Start building from scratch`}
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,7 +12,6 @@ export const ImportResumeListItem = () => {
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<DownloadSimple size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>{t`Import an existing resume`}</span>
|
||||
@ -21,6 +20,9 @@ export const ImportResumeListItem = () => {
|
||||
</>
|
||||
}
|
||||
description={t`LinkedIn, JSON Resume, etc.`}
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -143,11 +143,11 @@ export const ResumeListItem = ({ resume }: Props) => {
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<BaseListItem
|
||||
onClick={onOpen}
|
||||
className="group"
|
||||
title={resume.title}
|
||||
description={t`Last updated ${lastUpdated}`}
|
||||
end={dropdownMenu}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="p-0" sideOffset={-100} alignOffset={100}>
|
||||
@ -160,7 +160,7 @@ export const ResumeListItem = ({ resume }: Props) => {
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="aspect-[1/1.4142] w-60 rounded-sm object-cover"
|
||||
src={`${url}?cache=${new Date().getTime()}`}
|
||||
src={`${url}?cache=${Date.now()}`}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@ -193,7 +193,7 @@ export const ResumeListItem = ({ resume }: Props) => {
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<ContextMenuItem className="text-error" onClick={onDelete}>
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
{t`Delete`}
|
||||
</ContextMenuItem>
|
||||
|
||||
@ -25,7 +25,7 @@ export const ListView = () => {
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
|
||||
@ -23,8 +23,10 @@ export const ResumesPage = () => {
|
||||
|
||||
<Tabs
|
||||
value={layout}
|
||||
onValueChange={(value) => setLayout(value as Layout)}
|
||||
className="space-y-4"
|
||||
onValueChange={(value) => {
|
||||
setLayout(value as Layout);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.h1
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { msg, t } from "@lingui/macro";
|
||||
import { QrCode } from "@phosphor-icons/react";
|
||||
import {
|
||||
Alert,
|
||||
@ -38,7 +39,7 @@ import { queryClient } from "@/client/libs/query-client";
|
||||
import { useDisable2FA, useEnable2FA, useSetup2FA } from "@/client/services/auth";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
// We're using the pre-existing "mode" state to determine the stage of 2FA setup the user is in.
|
||||
// We're using the pre-existing "mode" state to determine the stage of 2FA set up the user is in.
|
||||
// - "create" mode is used to enable 2FA.
|
||||
// - "update" mode is used to verify 2FA, displaying a QR Code, once enabled.
|
||||
// - "duplicate" mode is used to display the backup codes after initial verification.
|
||||
@ -46,8 +47,9 @@ import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
const formSchema = z.object({
|
||||
uri: z.literal("").or(z.string().optional()),
|
||||
// eslint-disable-next-line lingui/t-call-in-function
|
||||
code: z.literal("").or(z.string().regex(/^\d{6}$/, t`Code must be exactly 6 digits long.`)),
|
||||
code: z
|
||||
.literal("")
|
||||
.or(z.string().regex(/^\d{6}$/, i18n._(msg`Code must be exactly 6 digits long.`))),
|
||||
backupCodes: z.array(z.string()),
|
||||
});
|
||||
|
||||
@ -81,7 +83,7 @@ export const TwoFactorDialog = () => {
|
||||
form.setValue("uri", data.message);
|
||||
};
|
||||
|
||||
if (isCreate) initialize();
|
||||
if (isCreate) void initialize();
|
||||
}, [isCreate]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
@ -178,7 +180,7 @@ export const TwoFactorDialog = () => {
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
<QRCodeSVG value={field.value!} size={256} className="mx-auto" />
|
||||
<QRCodeSVG value={field.value ?? ""} size={256} className="mx-auto" />
|
||||
<Input readOnly {...field} className="opacity-75" />
|
||||
</div>
|
||||
</FormControl>
|
||||
@ -232,7 +234,12 @@ export const TwoFactorDialog = () => {
|
||||
{isCreate && <Button disabled={loading}>{t`Continue`}</Button>}
|
||||
{isUpdate && (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => open("create")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ export const AccountSettings = () => {
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<form className="grid gap-6 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="picture"
|
||||
control={form.control}
|
||||
@ -119,22 +119,22 @@ export const AccountSettings = () => {
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{t`Picture`}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." {...field} value={field.value || ""} />
|
||||
<Input placeholder="https://..." {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
{!user.picture && (
|
||||
<>
|
||||
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
|
||||
<input ref={inputRef} hidden type="file" onChange={onSelectImage} />
|
||||
|
||||
<motion.button
|
||||
disabled={isUploading}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<UploadSimple />
|
||||
</motion.button>
|
||||
|
||||
@ -70,7 +70,7 @@ export const DangerZoneSettings = () => {
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onDelete)} className="grid gap-6 sm:grid-cols-2">
|
||||
<form className="grid gap-6 sm:grid-cols-2" onSubmit={form.handleSubmit(onDelete)}>
|
||||
<FormField
|
||||
name="deleteConfirm"
|
||||
control={form.control}
|
||||
|
||||
@ -22,7 +22,7 @@ const formSchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
// eslint-disable-next-line lingui/t-call-in-function
|
||||
.regex(/^sk-[a-zA-Z0-9]+$/, t`That doesn't look like a valid OpenAI API key.`)
|
||||
.regex(/^sk-[\dA-Za-z]+$/, t`That doesn't look like a valid OpenAI API key.`)
|
||||
.default(""),
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@ export const OpenAISettings = () => {
|
||||
defaultValues: { apiKey: apiKey ?? "" },
|
||||
});
|
||||
|
||||
const onSubmit = async ({ apiKey }: FormValues) => {
|
||||
const onSubmit = ({ apiKey }: FormValues) => {
|
||||
setApiKey(apiKey);
|
||||
};
|
||||
|
||||
@ -74,7 +74,7 @@ export const OpenAISettings = () => {
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<form className="grid gap-6 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="apiKey"
|
||||
control={form.control}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { Combobox } from "@reactive-resume/ui";
|
||||
import { Form, FormDescription, FormField, FormItem, FormLabel } from "@reactive-resume/ui";
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -36,7 +42,7 @@ export const ProfileSettings = () => {
|
||||
const onReset = () => {
|
||||
if (!user) return;
|
||||
|
||||
form.reset({ theme, locale: user.locale ?? "en-US" });
|
||||
form.reset({ theme, locale: user.locale });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
@ -64,7 +70,7 @@ export const ProfileSettings = () => {
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<form className="grid gap-6 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="theme"
|
||||
control={form.control}
|
||||
@ -75,12 +81,12 @@ export const ProfileSettings = () => {
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ label: t`System`, value: "system" },
|
||||
{ label: t`Light`, value: "light" },
|
||||
{ label: t`Dark`, value: "dark" },
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
@ -76,7 +76,7 @@ export const SecuritySettings = () => {
|
||||
<AccordionTrigger>{t`Password`}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<form className="grid gap-6 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
@ -101,7 +101,7 @@ export const SecuritySettings = () => {
|
||||
</FormControl>
|
||||
{fieldState.error && (
|
||||
<FormDescription className="text-error-foreground">
|
||||
{fieldState.error?.message}
|
||||
{fieldState.error.message}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
@ -151,11 +151,21 @@ export const SecuritySettings = () => {
|
||||
)}
|
||||
|
||||
{user?.twoFactorEnabled ? (
|
||||
<Button variant="outline" onClick={() => open("delete")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
open("delete");
|
||||
}}
|
||||
>
|
||||
{t`Disable 2FA`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => open("create")}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
open("create");
|
||||
}}
|
||||
>
|
||||
{t`Enable 2FA`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
21
apps/client/src/pages/home/components/donation-banner.tsx
Normal file
21
apps/client/src/pages/home/components/donation-banner.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/* eslint-disable lingui/no-unlocalized-strings */
|
||||
|
||||
import { HandHeart } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const DonationBanner = () => (
|
||||
<motion.a
|
||||
href="https://opencollective.com/Reactive-Resume"
|
||||
target="_blank"
|
||||
whileHover={{ height: 48 }}
|
||||
initial={{ opacity: 0, y: -50, height: 32 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
className="hidden w-screen items-center justify-center gap-x-2 bg-zinc-800 text-xs font-bold leading-relaxed text-zinc-50 lg:flex"
|
||||
>
|
||||
<HandHeart weight="bold" size={14} className="shrink-0" />
|
||||
<span>
|
||||
If this project has helped you, please consider donating to Reactive Resume as we're running
|
||||
out of server resources with the increasing number of users.
|
||||
</span>
|
||||
</motion.a>
|
||||
);
|
||||
@ -1,5 +1,6 @@
|
||||
import { t } from "@lingui/macro";
|
||||
import { Separator } from "@reactive-resume/ui";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { LocaleSwitch } from "@/client/components/locale-switch";
|
||||
@ -24,8 +25,11 @@ export const Footer = () => (
|
||||
</div>
|
||||
|
||||
<div className="relative col-start-4 flex flex-col items-end justify-end">
|
||||
<div className="mb-14">
|
||||
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=Reactive-Resume">
|
||||
<div className="mb-14 space-y-6 text-right">
|
||||
<a
|
||||
className="block"
|
||||
href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=Reactive-Resume"
|
||||
>
|
||||
<img
|
||||
src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg"
|
||||
alt="Powered by DigitalOcean"
|
||||
@ -39,6 +43,11 @@ export const Footer = () => (
|
||||
width="150px"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<Link
|
||||
to="/meta/privacy-policy"
|
||||
className="block text-sm font-medium"
|
||||
>{t`Privacy Policy`}</Link>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 right-0 lg:space-x-2">
|
||||
|
||||
@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/client/components/logo";
|
||||
|
||||
import { ProductHuntBanner } from "./product-hunt-banner";
|
||||
import { DonationBanner } from "./donation-banner";
|
||||
|
||||
export const Header = () => (
|
||||
<motion.header
|
||||
@ -11,7 +11,7 @@ export const Header = () => (
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: 0.3, duration: 0.3 } }}
|
||||
>
|
||||
<ProductHuntBanner />
|
||||
<DonationBanner />
|
||||
|
||||
<div className="bg-gradient-to-b from-background to-transparent py-3">
|
||||
<div className="container flex items-center justify-between">
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
/* eslint-disable lingui/no-unlocalized-strings */
|
||||
|
||||
import { RocketLaunch } from "@phosphor-icons/react";
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const ProductHuntBanner = () => {
|
||||
const timezonePST = "America/Los_Angeles";
|
||||
|
||||
const startTime = dayjs.tz("2023-11-24 00:01", timezonePST);
|
||||
const endTime = dayjs.tz("2023-11-25 00:00", timezonePST);
|
||||
const currentTime = dayjs().tz(timezonePST);
|
||||
|
||||
const isLaunchDay = currentTime.isAfter(startTime) && currentTime.isBefore(endTime);
|
||||
|
||||
if (!isLaunchDay) return null;
|
||||
|
||||
return (
|
||||
<a href="https://www.producthunt.com/posts/reactive-resume-v4" target="_blank" rel="noreferrer">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, height: 32 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: 0.9, duration: 0.3 } }}
|
||||
whileHover={{ height: 48 }}
|
||||
className="flex w-screen items-center justify-center gap-x-1.5 bg-orange-600 text-xs font-bold leading-relaxed text-orange-50"
|
||||
>
|
||||
<RocketLaunch weight="bold" size={12} />
|
||||
<span>Support Reactive Resume on Product Hunt today!</span>
|
||||
</motion.div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
113
apps/client/src/pages/home/meta/privacy-policy/page.tsx
Normal file
113
apps/client/src/pages/home/meta/privacy-policy/page.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
/* eslint-disable lingui/no-unlocalized-strings */
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
export const PrivacyPolicyPage = () => (
|
||||
<main className="relative isolate bg-background">
|
||||
<Helmet prioritizeSeoTags>
|
||||
<title>
|
||||
{t`Privacy Policy`} - {t`Reactive Resume`}
|
||||
</title>
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
/>
|
||||
</Helmet>
|
||||
|
||||
<section
|
||||
id="privacy-policy"
|
||||
className="container prose prose-zinc relative max-w-4xl py-32 dark:prose-invert"
|
||||
>
|
||||
<h1 className="mb-4">{t`Privacy Policy`}</h1>
|
||||
<h6 className="text-sm">Last updated on 3rd May 2024</h6>
|
||||
|
||||
<hr className="my-6" />
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<h2 className="mb-2">Introduction</h2>
|
||||
<p>
|
||||
This privacy policy outlines how we collect, use, and protect the personal information
|
||||
you provide when using our web application. By accessing or using Reactive Resume, you
|
||||
agree to the collection and use of information in accordance with this policy.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Information Collection and Use</h2>
|
||||
<p>
|
||||
For a better experience while using our Service, we may require you to provide us with
|
||||
certain personally identifiable information, including but not limited to your name and
|
||||
email address. The information that we collect will be used to contact or identify you
|
||||
primarily for the following purposes:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Account Creation:</strong> to allow you to create and manage your account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Functionality:</strong> to enable the various features of the application that
|
||||
you choose to utilize, such as building and saving resumes.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">How We Collect Information</h2>
|
||||
<p>
|
||||
All personal data is provided directly by you. We collect information through our web
|
||||
application when you voluntarily provide it to us as part of using our service.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Data Security</h2>
|
||||
<p>
|
||||
Reactive Resume is committed to ensuring the security of your data. Our application and
|
||||
database are hosted on a secure server from DigitalOcean, which has both SOC 2 and SOC 3
|
||||
compliance, ensuring that your data is protected with industry-standard security
|
||||
measures.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Data Retention</h2>
|
||||
<p>
|
||||
We retain your personal data as long as your account is active or as needed to provide
|
||||
you services. If you do not use your account for 6 months, your personal information is
|
||||
automatically deleted from our servers. You may also delete your data at any time via
|
||||
the user dashboard.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Third-Party Disclosure</h2>
|
||||
<p>
|
||||
We do not share your personal information with third parties, ensuring your data is used
|
||||
exclusively for the purposes stated in this privacy policy.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Changes to This Privacy Policy</h2>
|
||||
<p>
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by
|
||||
posting the new Privacy Policy on this page. You are advised to review this Privacy
|
||||
Policy periodically for any changes.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2 className="mb-2">Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions or suggestions about our Privacy Policy, do not hesitate to
|
||||
contact us at <code>hello[at]amruthpillai[dot]com</code>.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@ -25,7 +25,7 @@ export const ContributorsSection = () => {
|
||||
|
||||
{loading && (
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-center gap-3">
|
||||
{Array(30)
|
||||
{Array.from({ length: 30 })
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<motion.div
|
||||
|
||||
@ -58,11 +58,8 @@ const Question2 = () => (
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="prose max-w-none dark:prose-invert">
|
||||
<p>
|
||||
It's not much honestly.{" "}
|
||||
<a href="https://pillai.xyz/digitalocean" rel="noreferrer" target="_blank">
|
||||
DigitalOcean
|
||||
</a>{" "}
|
||||
has graciously sponsored their infrastructure to allow me to host Reactive Resume on their
|
||||
It's not much honestly. <a href="https://pillai.xyz/digitalocean">DigitalOcean</a> has
|
||||
graciously sponsored their infrastructure to allow me to host Reactive Resume on their
|
||||
platform. There's only the fee I pay to dependent services to send emails, renew the domain,
|
||||
etc.
|
||||
</p>
|
||||
@ -76,16 +73,14 @@ const Question2 = () => (
|
||||
<p>
|
||||
But if you do feel like supporting the developer and the future development of Reactive
|
||||
Resume, please donate (<em>only if you have some extra money lying around</em>) on my{" "}
|
||||
<a href="https://github.com/sponsors/AmruthPillai/" rel="noreferrer" target="_blank">
|
||||
GitHub Sponsors page
|
||||
</a>
|
||||
. You can choose to donate one-time or sponsor a recurring donation.
|
||||
<a href="https://github.com/sponsors/AmruthPillai/">GitHub Sponsors page</a>. You can choose
|
||||
to donate one-time or sponsor a recurring donation.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Alternatively, if you are in the US, or you are a part of a large educational institution or
|
||||
corporate organization, you can{" "}
|
||||
<a href="https://opencollective.com/reactive-resume" rel="noreferrer" target="_blank">
|
||||
<a href="https://opencollective.com/reactive-resume">
|
||||
support the project through Open Collective
|
||||
</a>
|
||||
. We are fiscally hosted through Open Collective Europe, which means your donations and
|
||||
@ -155,10 +150,10 @@ const Question4 = () => {
|
||||
{languages.map((language) => (
|
||||
<a
|
||||
key={language.id}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
href={`https://crowdin.com/translate/reactive-resume/all/en-${language.editorCode}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="relative bg-secondary-accent font-medium transition-colors hover:bg-primary hover:text-background">
|
||||
<span className="px-2 py-1">{language.name}</span>
|
||||
@ -219,19 +214,6 @@ const Question5 = () => (
|
||||
to OpenAI are also sent directly to their service and does not hit the app servers at all.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The policy behind "bring your own key" (BYOK) is{" "}
|
||||
<a
|
||||
href="https://community.openai.com/t/openais-bring-your-own-key-policy/14538/46"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
still being discussed
|
||||
</a>{" "}
|
||||
and probably might change over a period of time, but while it's available, I would keep the
|
||||
feature on the app.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You are free to turn off all AI features (and not be aware of it's existence) simply by not
|
||||
adding a key in the Settings page and still make use of all the useful features that
|
||||
|
||||
@ -91,7 +91,6 @@ export const FeaturesSection = () => {
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
<img src="https://cdn.simpleicons.org/redis" alt="Redis" width={14} height={14} />
|
||||
</div>
|
||||
),
|
||||
title: t`Powered by`,
|
||||
|
||||
@ -26,22 +26,18 @@ export const HeroCTA = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/auth/login">{t`Get Started`}</Link>
|
||||
</Button>
|
||||
return (
|
||||
<>
|
||||
<Button asChild size="lg">
|
||||
<Link to="/auth/login">{t`Get Started`}</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" variant="link">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer nofollow">
|
||||
<Book className="mr-3" />
|
||||
{t`Learn more`}
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
<Button asChild size="lg" variant="link">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer nofollow">
|
||||
<Book className="mr-3" />
|
||||
{t`Learn more`}
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -26,8 +26,6 @@ export const HeroSection = () => (
|
||||
<Badge>{t`Version 4`}</Badge>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://docs.rxresu.me/overview/features"
|
||||
className={cn(buttonVariants({ variant: "link" }), "space-x-2 text-left")}
|
||||
>
|
||||
|
||||
@ -19,7 +19,9 @@ export const Counter = ({ from, to }: CounterProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
return () => controls.stop();
|
||||
return () => {
|
||||
controls.stop();
|
||||
};
|
||||
}, [from, to, isInView]);
|
||||
|
||||
return (
|
||||
|
||||
@ -9,9 +9,9 @@ type Statistic = {
|
||||
|
||||
export const StatisticsSection = () => {
|
||||
const stats: Statistic[] = [
|
||||
{ name: t`GitHub Stars`, value: 12800 },
|
||||
{ name: t`Users Signed Up`, value: 300000 },
|
||||
{ name: t`Resumes Generated`, value: 400000 },
|
||||
{ name: t`GitHub Stars`, value: 19_500 },
|
||||
{ name: t`Users Signed Up`, value: 500_000 },
|
||||
{ name: t`Resumes Generated`, value: 700_000 },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@ -20,7 +20,7 @@ export const TemplatesSection = () => (
|
||||
transition: {
|
||||
x: {
|
||||
duration: 30,
|
||||
repeat: Infinity,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatType: "mirror",
|
||||
},
|
||||
},
|
||||
|
||||
@ -12,6 +12,11 @@ import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { findResumeByUsernameSlug, usePrintResume } from "@/client/services/resume";
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
const win = window.open(url, "_blank");
|
||||
if (win) win.focus();
|
||||
};
|
||||
|
||||
export const PublicResumePage = () => {
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
@ -21,9 +26,11 @@ export const PublicResumePage = () => {
|
||||
const format = resume.metadata.page.format;
|
||||
|
||||
const updateResumeInFrame = useCallback(() => {
|
||||
if (!frameRef.current || !frameRef.current.contentWindow) return;
|
||||
if (!frameRef.current?.contentWindow) return;
|
||||
const message = { type: "SET_RESUME", payload: resume };
|
||||
(() => frameRef.current.contentWindow.postMessage(message, "*"))();
|
||||
(() => {
|
||||
frameRef.current.contentWindow.postMessage(message, "*");
|
||||
})();
|
||||
}, [frameRef, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -33,10 +40,10 @@ export const PublicResumePage = () => {
|
||||
}, [frameRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameRef.current || !frameRef.current.contentWindow) return;
|
||||
if (!frameRef.current?.contentWindow) return;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (!frameRef.current || !frameRef.current.contentWindow) return;
|
||||
if (!frameRef.current?.contentWindow) return;
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === "PAGE_LOADED") {
|
||||
@ -56,11 +63,6 @@ export const PublicResumePage = () => {
|
||||
const onDownloadPdf = async () => {
|
||||
const { url } = await printResume({ id });
|
||||
|
||||
const openInNewTab = (url: string) => {
|
||||
const win = window.open(url, "_blank");
|
||||
if (win) win.focus();
|
||||
};
|
||||
|
||||
openInNewTab(url);
|
||||
};
|
||||
|
||||
@ -77,9 +79,8 @@ export const PublicResumePage = () => {
|
||||
className="mx-auto mb-6 mt-16 overflow-hidden rounded shadow-xl print:m-0 print:shadow-none"
|
||||
>
|
||||
<iframe
|
||||
title={title}
|
||||
ref={frameRef}
|
||||
scrolling="no"
|
||||
title={title}
|
||||
src="/artboard/preview"
|
||||
style={{ width: `${pageSizeMap[format].width}mm`, overflow: "hidden" }}
|
||||
/>
|
||||
@ -112,16 +113,16 @@ export const PublicResumePage = () => {
|
||||
|
||||
export const publicLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
|
||||
try {
|
||||
const username = params.username as string;
|
||||
const slug = params.slug as string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const username = params.username!;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const slug = params.slug!;
|
||||
|
||||
const resume = await queryClient.fetchQuery({
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: ["resume", { username, slug }],
|
||||
queryFn: () => findResumeByUsernameSlug({ username, slug }),
|
||||
});
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return redirect("/");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user