merge branch main

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,7 +46,6 @@ export const SectionDialog = <T extends SectionItem>({
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore((state) => {
if (!id) return null;
return get(state.resume.data.sections, id);
}) as SectionWithItem<T> | null;
@ -59,7 +58,7 @@ export const SectionDialog = <T extends SectionItem>({
if (isOpen) onReset();
}, [isOpen, payload]);
const onSubmit = async (values: T) => {
const onSubmit = (values: T) => {
if (!section) return;
if (isCreate || isDuplicate) {

View File

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

View File

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

View File

@ -46,12 +46,24 @@ export const SectionOptions = ({ id }: Props) => {
const hasItems = useMemo(() => "items" in section, [section]);
const isCustomSection = useMemo(() => id.startsWith("custom"), [id]);
const onCreate = () => open("create", { id });
const toggleVisibility = () => setValue(`sections.${id}.visible`, !section.visible);
const onResetName = () => setValue(`sections.${id}.name`, originalName);
const onChangeColumns = (value: string) => setValue(`sections.${id}.columns`, Number(value));
const onResetItems = () => setValue(`sections.${id}.items`, []);
const onRemove = () => removeSection(id);
const onCreate = () => {
open("create", { id });
};
const toggleVisibility = () => {
setValue(`sections.${id}.visible`, !section.visible);
};
const onResetName = () => {
setValue(`sections.${id}.name`, originalName);
};
const onChangeColumns = (value: string) => {
setValue(`sections.${id}.columns`, Number(value));
};
const onResetItems = () => {
setValue(`sections.${id}.items`, []);
};
const onRemove = () => {
removeSection(id);
};
return (
<DropdownMenu>
@ -94,8 +106,8 @@ export const SectionOptions = ({ id }: Props) => {
<Button
size="icon"
variant="link"
onClick={onResetName}
className="absolute inset-y-0 right-0"
onClick={onResetName}
>
<ArrowCounterClockwise />
</Button>

View File

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

View File

@ -11,6 +11,7 @@ import { SectionOptions } from "./shared/section-options";
export const SummarySection = () => {
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(state) => state.resume.data.sections.summary ?? defaultSections.summary,
);
@ -30,10 +31,12 @@ export const SummarySection = () => {
<main className={cn(!section.visible && "opacity-50")}>
<RichInput
content={section.content}
onChange={(value) => setValue("sections.summary.content", value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
onChange={(value) => {
setValue("sections.summary.content", value);
}}
/>
</main>
</section>

View File

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

View File

@ -9,26 +9,26 @@ import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
const onJsonExport = () => {
const { resume } = useResumeStore.getState();
const filename = `reactive_resume-${resume.id}.json`;
const resumeJSON = JSON.stringify(resume.data, null, 2);
saveAs(new Blob([resumeJSON], { type: "application/json" }), filename);
};
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
export const ExportSection = () => {
const { printResume, loading } = usePrintResume();
const onJsonExport = () => {
const { resume } = useResumeStore.getState();
const filename = `reactive_resume-${resume.id}.json`;
const resumeJSON = JSON.stringify(resume.data, null, 2);
saveAs(new Blob([resumeJSON], { type: "application/json" }), filename);
};
const onPdfExport = async () => {
const { resume } = useResumeStore.getState();
const { url } = await printResume({ id: resume.id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
@ -43,11 +43,11 @@ export const ExportSection = () => {
<main className="grid gap-y-4">
<Card
onClick={onJsonExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
)}
onClick={onJsonExport}
>
<FileJs size={22} />
<CardContent className="flex-1">
@ -59,12 +59,12 @@ export const ExportSection = () => {
</Card>
<Card
onClick={onPdfExport}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-auto cursor-pointer flex-row items-center gap-x-5 px-4 pb-3 pt-1",
loading && "pointer-events-none cursor-progress opacity-75",
)}
onClick={onPdfExport}
>
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}

View File

@ -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>

View File

@ -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>

View File

@ -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.`}

View File

@ -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}>

View File

@ -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" />

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}
};

View File

@ -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>

View File

@ -77,6 +77,9 @@ export const ImportDialog = () => {
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 "";
@ -255,11 +257,11 @@ export const ImportDialog = () => {
<FormLabel>{t`File`}</FormLabel>
<FormControl>
<Input
type="file"
key={`${accept}-${filetype}`}
type="file"
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>

View File

@ -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,
});

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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 } }}

View File

@ -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">

View File

@ -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");
}}
/>
);
};

View File

@ -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");
}}
/>
);
};

View File

@ -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>

View File

@ -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"

View File

@ -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

View File

@ -39,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.
@ -83,7 +83,7 @@ export const TwoFactorDialog = () => {
form.setValue("uri", data.message);
};
if (isCreate) initialize();
if (isCreate) void initialize();
}, [isCreate]);
const onSubmit = async (values: FormValues) => {
@ -234,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>

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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>
)}

View File

@ -5,9 +5,8 @@ import { motion } from "framer-motion";
export const DonationBanner = () => (
<motion.a
target="_blank"
rel="noreferrer"
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 } }}

View File

@ -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">

View 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>
);

View File

@ -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

View File

@ -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

View File

@ -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`,

View File

@ -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>
</>
);
};

View File

@ -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")}
>

View File

@ -19,7 +19,9 @@ export const Counter = ({ from, to }: CounterProps) => {
},
});
return () => controls.stop();
return () => {
controls.stop();
};
}, [from, to, isInView]);
return (

View File

@ -9,9 +9,9 @@ type Statistic = {
export const StatisticsSection = () => {
const stats: Statistic[] = [
{ name: t`GitHub Stars`, value: 19500 },
{ name: t`Users Signed Up`, value: 500000 },
{ name: t`Resumes Generated`, value: 700000 },
{ name: t`GitHub Stars`, value: 19_500 },
{ name: t`Users Signed Up`, value: 500_000 },
{ name: t`Resumes Generated`, value: 700_000 },
];
return (

View File

@ -20,7 +20,7 @@ export const TemplatesSection = () => (
transition: {
x: {
duration: 30,
repeat: Infinity,
repeat: Number.POSITIVE_INFINITY,
repeatType: "mirror",
},
},

View File

@ -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("/");
}
};