release: v4.1.0

This commit is contained in:
Amruth Pillai
2024-05-05 14:55:06 +02:00
parent 68252c35fc
commit e87b05a93a
282 changed files with 11461 additions and 10713 deletions

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

@ -188,11 +188,11 @@ export const LayoutSection = () => {
// 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;
}
}
};