refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View File

@ -0,0 +1,59 @@
import { HouseSimple, SidebarSimple } from "@phosphor-icons/react";
import { useBreakpoint } from "@reactive-resume/hooks";
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { Link } from "react-router-dom";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore } from "@/client/stores/resume";
export const BuilderHeader = () => {
const { isDesktop } = useBreakpoint();
const defaultPanelSize = isDesktop ? 25 : 0;
const toggle = useBuilderStore((state) => state.toggle);
const title = useResumeStore((state) => state.resume.title);
const isDragging = useBuilderStore(
(state) => state.panel.left.isDragging || state.panel.right.isDragging,
);
const leftPanelSize = useBuilderStore(
(state) => state.panel.left.ref?.getSize() ?? defaultPanelSize,
);
const rightPanelSize = useBuilderStore(
(state) => state.panel.right.ref?.getSize() ?? defaultPanelSize,
);
const onToggle = (side: "left" | "right") => toggle(side);
return (
<div
style={{ left: `${leftPanelSize}%`, right: `${rightPanelSize}%` }}
className={cn(
"fixed inset-x-0 top-0 z-[100] h-16 bg-secondary-accent/50 backdrop-blur-lg lg:z-20",
!isDragging && "transition-[left,right]",
)}
>
<div className="flex h-full items-center justify-between px-4">
<Button size="icon" variant="ghost" onClick={() => onToggle("left")}>
<SidebarSimple />
</Button>
<div className="flex items-center justify-center gap-x-1">
<Button asChild size="icon" variant="ghost">
<Link to="/dashboard/resumes">
<HouseSimple />
</Link>
</Button>
<span className="mr-2 text-xs opacity-40">{"/"}</span>
<h1 className="font-medium">{title}</h1>
</div>
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>
<SidebarSimple className="-scale-x-100" />
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,167 @@
import {
ArrowClockwise,
ArrowCounterClockwise,
CircleNotch,
ClockClockwise,
CubeFocus,
DownloadSimple,
Hash,
LineSegment,
LinkSimple,
MagnifyingGlassMinus,
MagnifyingGlassPlus,
} from "@phosphor-icons/react";
import { Button, Separator, Toggle, Tooltip } from "@reactive-resume/ui";
import { motion } from "framer-motion";
import { usePrintResume } from "@/client/services/resume";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore, useTemporalResumeStore } from "@/client/stores/resume";
export const BuilderToolbar = () => {
const setValue = useResumeStore((state) => state.setValue);
const undo = useTemporalResumeStore((state) => state.undo);
const redo = useTemporalResumeStore((state) => state.redo);
const transformRef = useBuilderStore((state) => state.transform.ref);
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
const pageOptions = useResumeStore((state) => state.resume.data.metadata.page.options);
const { printResume, loading } = usePrintResume();
const onPrint = async () => {
const { url } = await printResume({ id });
const openInNewTab = (url: string) => {
const win = window.open(url, "_blank");
if (win) win.focus();
};
openInNewTab(url);
};
return (
<motion.div
initial={{ opacity: 0, bottom: -64 }}
whileHover={{ opacity: 1, bottom: 0 }}
animate={{ opacity: 0.3, bottom: -28 }}
className="fixed inset-x-0 mx-auto pb-4 pt-6 text-center"
>
<div className="inline-flex items-center justify-center rounded-full bg-background px-4 shadow-xl">
{/* Undo */}
<Tooltip content="Undo">
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => undo()}>
<ArrowCounterClockwise />
</Button>
</Tooltip>
{/* Redo */}
<Tooltip content="Redo">
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => redo()}>
<ArrowClockwise />
</Button>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Zoom In */}
<Tooltip content="Zoom In">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomIn(0.2)}
>
<MagnifyingGlassPlus />
</Button>
</Tooltip>
{/* Zoom Out */}
<Tooltip content="Zoom Out">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.zoomOut(0.2)}
>
<MagnifyingGlassMinus />
</Button>
</Tooltip>
<Tooltip content="Reset Zoom">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.resetTransform()}
>
<ClockClockwise />
</Button>
</Tooltip>
{/* Center Artboard */}
<Tooltip content="Center Artboard">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={() => transformRef?.centerView()}
>
<CubeFocus />
</Button>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Toggle Page Break Line */}
<Tooltip content="Toggle Page Break Line">
<Toggle
className="rounded-none"
pressed={pageOptions.breakLine}
onPressedChange={(pressed) => {
setValue("metadata.page.options.breakLine", pressed);
}}
>
<LineSegment />
</Toggle>
</Tooltip>
{/* Toggle Page Numbers */}
<Tooltip content="Toggle Page Numbers">
<Toggle
className="rounded-none"
pressed={pageOptions.pageNumbers}
onPressedChange={(pressed) => {
setValue("metadata.page.options.pageNumbers", pressed);
}}
>
<Hash />
</Toggle>
</Tooltip>
<Separator orientation="vertical" className="h-9" />
{/* Copy Link to Resume */}
<Tooltip content="Copy Link to Resume">
<Button size="icon" variant="ghost" className="rounded-none" disabled={!isPublic}>
<LinkSimple />
</Button>
</Tooltip>
{/* Download PDF */}
<Tooltip content="Download PDF">
<Button
size="icon"
variant="ghost"
className="rounded-none"
onClick={onPrint}
disabled={loading}
>
{loading ? <CircleNotch className="animate-spin" /> : <DownloadSimple />}
</Button>
</Tooltip>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,100 @@
import { useBreakpoint } from "@reactive-resume/hooks";
import { Panel, PanelGroup, PanelResizeHandle, Sheet, SheetContent } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { Outlet } from "react-router-dom";
import { useBuilderStore } from "@/client/stores/builder";
import { BuilderHeader } from "./_components/header";
import { BuilderToolbar } from "./_components/toolbar";
import { LeftSidebar } from "./sidebars/left";
import { RightSidebar } from "./sidebars/right";
const OutletSlot = () => (
<>
<BuilderHeader />
<div className="absolute inset-0">
<Outlet />
</div>
<BuilderToolbar />
</>
);
export const BuilderLayout = () => {
const { isDesktop } = useBreakpoint();
const panel = useBuilderStore((state) => state.panel);
const sheet = useBuilderStore((state) => state.sheet);
const onOpenAutoFocus = (event: Event) => event.preventDefault();
if (isDesktop) {
return (
<div className="relative h-full w-full overflow-hidden">
<PanelGroup direction="horizontal">
<Panel
collapsible
minSize={20}
maxSize={35}
defaultSize={28}
ref={panel.left.setRef}
className={cn("z-10 bg-background", !panel.left.isDragging && "transition-[flex]")}
>
<LeftSidebar />
</Panel>
<PanelResizeHandle
isDragging={panel.left.isDragging}
onDragging={panel.left.setDragging}
/>
<Panel>
<OutletSlot />
</Panel>
<PanelResizeHandle
isDragging={panel.right.isDragging}
onDragging={panel.right.setDragging}
/>
<Panel
collapsible
minSize={20}
maxSize={35}
defaultSize={28}
ref={panel.right.setRef}
className={cn("z-10 bg-background", !panel.right.isDragging && "transition-[flex]")}
>
<RightSidebar />
</Panel>
</PanelGroup>
</div>
);
}
return (
<div className="relative">
<Sheet open={sheet.left.open} onOpenChange={sheet.left.setOpen}>
<SheetContent
side="left"
className="p-0 sm:max-w-xl"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
>
<LeftSidebar />
</SheetContent>
</Sheet>
<OutletSlot />
<Sheet open={sheet.right.open} onOpenChange={sheet.right.setOpen}>
<SheetContent
side="right"
className="p-0 sm:max-w-xl"
showClose={false}
onOpenAutoFocus={onOpenAutoFocus}
>
<RightSidebar />
</SheetContent>
</Sheet>
</div>
);
};

View File

@ -0,0 +1,98 @@
import { ResumeDto } from "@reactive-resume/dto";
import { SectionKey } from "@reactive-resume/schema";
import {
Artboard,
PageBreakLine,
PageGrid,
PageNumber,
PageWrapper,
Rhyhorn,
} from "@reactive-resume/templates";
import { pageSizeMap } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import { Helmet } from "react-helmet-async";
import { LoaderFunction, redirect } from "react-router-dom";
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { queryClient } from "@/client/libs/query-client";
import { findResumeById } from "@/client/services/resume";
import { useBuilderStore } from "@/client/stores/builder";
import { useResumeStore } from "@/client/stores/resume";
export const BuilderPage = () => {
const title = useResumeStore((state) => state.resume.title);
const resume = useResumeStore((state) => state.resume.data);
const setTransformRef = useBuilderStore((state) => state.transform.setRef);
const { pageHeight, showBreakLine, showPageNumbers } = useMemo(() => {
const { format, options } = resume.metadata.page;
return {
pageHeight: pageSizeMap[format].height,
showBreakLine: options.breakLine,
showPageNumbers: options.pageNumbers,
};
}, [resume.metadata.page]);
return (
<>
<Helmet>
<title>{title} - Reactive Resume</title>
</Helmet>
<TransformWrapper
centerOnInit
minScale={0.4}
initialScale={1}
limitToBounds={false}
velocityAnimation={{ disabled: true }}
ref={(ref: ReactZoomPanPinchRef) => setTransformRef(ref)}
>
<TransformComponent wrapperClass="!w-screen !h-screen">
<PageGrid $offset={32}>
<AnimatePresence presenceAffectsLayout>
{resume.metadata.layout.map((columns, pageIndex) => (
<motion.div
layout
key={pageIndex}
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
>
<Artboard resume={resume}>
<PageWrapper>
{showPageNumbers && <PageNumber>Page {pageIndex + 1}</PageNumber>}
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
{showBreakLine && <PageBreakLine $pageHeight={pageHeight} />}
</PageWrapper>
</Artboard>
</motion.div>
))}
</AnimatePresence>
</PageGrid>
</TransformComponent>
</TransformWrapper>
</>
);
};
export const builderLoader: LoaderFunction<ResumeDto> = async ({ params }) => {
try {
const id = params.id as string;
const resume = await queryClient.fetchQuery({
queryKey: ["resume", { id }],
queryFn: () => findResumeById({ id }),
});
useResumeStore.setState({ resume });
useResumeStore.temporal.getState().clear();
return resume;
} catch (error) {
return redirect("/dashboard");
}
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { awardSchema, defaultAward } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = awardSchema;
type FormValues = z.infer<typeof formSchema>;
export const AwardsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultAward,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="awards" form={form} defaultValues={defaultAward}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="title"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} placeholder="3rd Runner Up" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="awarder"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Awarder</FormLabel>
<FormControl>
<Input {...field} placeholder="TechCrunch Disrupt SF" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://techcrunch.com/events/disrupt-sf-2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { certificationSchema, defaultCertification } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = certificationSchema;
type FormValues = z.infer<typeof formSchema>;
export const CertificationsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultCertification,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="certifications" form={form} defaultValues={defaultCertification}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Web Developer Bootcamp" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="issuer"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Issuer</FormLabel>
<FormControl>
<Input {...field} placeholder="Udemy" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2019" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://udemy.com/certificate/UC-..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,195 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { CustomSection, customSectionSchema, defaultCustomSection } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
Slider,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
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 { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
export const CustomSectionDialog = () => {
const { payload } = useDialog<CustomSection>("custom");
const form = useForm<FormValues>({
defaultValues: defaultCustomSection,
resolver: zodResolver(formSchema),
});
if (!payload) return null;
return (
<SectionDialog<FormValues>
form={form}
id={payload.id as DialogName}
defaultValues={defaultCustomSection}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="level"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Level</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={0}
max={5}
value={[field.value]}
orientation="horizontal"
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{field.value}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,140 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultEducation, educationSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = educationSchema;
type FormValues = z.infer<typeof formSchema>;
export const EducationDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultEducation,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="education" form={form} defaultValues={defaultEducation}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="institution"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Institution</FormLabel>
<FormControl>
<Input {...field} placeholder="Carnegie Mellon University" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="studyType"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Type of Study</FormLabel>
<FormControl>
<Input {...field} placeholder="Bachelor's Degree" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="area"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Area of Study</FormLabel>
<FormControl>
<Input {...field} placeholder="Computer Science" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="score"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Score</FormLabel>
<FormControl>
<Input {...field} placeholder="9.2 GPA" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Aug 2006 - Oct 2012" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.cmu.edu/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,126 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultExperience, experienceSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = experienceSchema;
type FormValues = z.infer<typeof formSchema>;
export const ExperienceDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultExperience,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="experience" form={form} defaultValues={defaultExperience}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="company"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Company</FormLabel>
<FormControl>
<Input {...field} placeholder="Alphabet Inc." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="position"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Position</FormLabel>
<FormControl>
<Input {...field} placeholder="Chief Executive Officer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Dec 2019 - Present" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="location"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Location</FormLabel>
<FormControl>
<Input {...field} placeholder="New York, NY" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.abc.xyz/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,93 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultInterest, interestSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = interestSchema;
type FormValues = z.infer<typeof formSchema>;
export const InterestsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultInterest,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="interests" form={form} defaultValues={defaultInterest}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Video Games" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,85 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultLanguage, languageSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Slider,
} from "@reactive-resume/ui";
import { getCEFRLevel } from "@reactive-resume/utils";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = languageSchema;
type FormValues = z.infer<typeof formSchema>;
export const LanguagesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultLanguage,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="languages" form={form} defaultValues={defaultLanguage}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="German" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="fluency"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Fluency</FormLabel>
<FormControl>
<Input {...field} placeholder="Native Speaker" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="fluencyLevel"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Fluency (CEFR)</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={1}
max={6}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{getCEFRLevel(field.value)}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultProfile, profileSchema } from "@reactive-resume/schema";
import {
Avatar,
AvatarImage,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = profileSchema;
type FormValues = z.infer<typeof formSchema>;
export const ProfilesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultProfile,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="profiles" form={form} defaultValues={defaultProfile}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="network"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Network</FormLabel>
<FormControl>
<Input {...field} placeholder="LinkedIn" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="username"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="johndoe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>URL</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://linkedin.com/in/johndoe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="icon"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel htmlFor="iconSlug">Icon</FormLabel>
<FormControl>
<div className="flex items-center gap-x-2">
<Avatar className="h-8 w-8 bg-white">
{field.value && (
<AvatarImage
className="p-1.5"
src={`https://cdn.simpleicons.org/${field.value}`}
/>
)}
</Avatar>
<Input {...field} id="iconSlug" placeholder="linkedin" />
</div>
</FormControl>
<FormMessage />
<FormDescription className="ml-10">
Powered by{" "}
<a
href="https://simpleicons.org/"
target="_blank"
rel="noopener noreferrer nofollow"
className="font-medium"
>
Simple Icons
</a>
</FormDescription>
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,160 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultProject, projectSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = projectSchema;
type FormValues = z.infer<typeof formSchema>;
export const ProjectsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultProject,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="projects" form={form} defaultValues={defaultProject}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Reactive Resume" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Open Source Resume Builder" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Sep 2018 - Present" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://rxresu.me" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultPublication, publicationSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = publicationSchema;
type FormValues = z.infer<typeof formSchema>;
export const PublicationsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultPublication,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="publications" form={form} defaultValues={defaultPublication}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The Great Gatsby" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="publisher"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Publisher</FormLabel>
<FormControl>
<Input {...field} placeholder="Charles Scribner's Sons" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Release Date</FormLabel>
<FormControl>
<Input {...field} placeholder="April 10, 1925" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://books.google.com/..." />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,98 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultReference, referenceSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = referenceSchema;
type FormValues = z.infer<typeof formSchema>;
export const ReferencesDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultReference,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="references" form={form} defaultValues={defaultReference}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Cosmo Kramer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Neighbour" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://linkedin.com/in/cosmo.kramer" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,133 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { X } from "@phosphor-icons/react";
import { defaultSkill, skillSchema } from "@reactive-resume/schema";
import {
Badge,
BadgeInput,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Slider,
} from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SectionDialog } from "../sections/shared/section-dialog";
const formSchema = skillSchema;
type FormValues = z.infer<typeof formSchema>;
export const SkillsDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultSkill,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="skills" form={form} defaultValues={defaultSkill}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Content Management" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="description"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} placeholder="Advanced" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="level"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Level</FormLabel>
<FormControl className="py-2">
<div className="flex items-center gap-x-4">
<Slider
{...field}
min={1}
max={5}
value={[field.value]}
orientation="horizontal"
onValueChange={(value) => field.onChange(value[0])}
/>
<span className="text-base font-bold">{field.value}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="keywords"
control={form.control}
render={({ field }) => (
<div className="col-span-2 space-y-3">
<FormItem>
<FormLabel>Keywords</FormLabel>
<FormControl>
<BadgeInput {...field} placeholder="WordPress, Joomla, Webflow etc." />
</FormControl>
<FormDescription>
You can add multiple keywords by separating them with a comma.
</FormDescription>
<FormMessage />
</FormItem>
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
<AnimatePresence>
{field.value.map((item, index) => (
<motion.div
layout
key={item}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
exit={{ opacity: 0, x: -50 }}
>
<Badge
className="cursor-pointer"
onClick={() => {
field.onChange(field.value.filter((v) => item !== v));
}}
>
<span className="mr-1">{item}</span>
<X size={12} weight="bold" />
</Badge>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,126 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { defaultVolunteer, volunteerSchema } from "@reactive-resume/schema";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RichInput,
} from "@reactive-resume/ui";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AiActions } from "@/client/components/ai-actions";
import { SectionDialog } from "../sections/shared/section-dialog";
import { URLInput } from "../sections/shared/url-input";
const formSchema = volunteerSchema;
type FormValues = z.infer<typeof formSchema>;
export const VolunteerDialog = () => {
const form = useForm<FormValues>({
defaultValues: defaultVolunteer,
resolver: zodResolver(formSchema),
});
return (
<SectionDialog<FormValues> id="volunteer" form={form} defaultValues={defaultVolunteer}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
name="organization"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Organization</FormLabel>
<FormControl>
<Input {...field} placeholder="Amnesty International" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="position"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Position</FormLabel>
<FormControl>
<Input {...field} placeholder="Recruiter" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Date</FormLabel>
<FormControl>
<Input {...field} placeholder="Dec 2016 - Aug 2017" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="location"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1">
<FormLabel>Location</FormLabel>
<FormControl>
<Input {...field} placeholder="New York, NY" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="url"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Website</FormLabel>
<FormControl>
<URLInput {...field} placeholder="https://www.amnesty.org/" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="summary"
control={form.control}
render={({ field }) => (
<FormItem className="col-span-1 sm:col-span-2">
<FormLabel>Summary</FormLabel>
<FormControl>
<RichInput
{...field}
content={field.value}
onChange={(value) => field.onChange(value)}
footer={(editor) => (
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SectionDialog>
);
};

View File

@ -0,0 +1,193 @@
import { Plus, PlusCircle } from "@phosphor-icons/react";
import {
Award,
Certification,
CustomSectionItem,
Education,
Experience,
Interest,
Language,
Profile,
Project,
Publication,
Reference,
Skill,
Volunteer,
} from "@reactive-resume/schema";
import { Button, ScrollArea, Separator } from "@reactive-resume/ui";
import { getCEFRLevel } from "@reactive-resume/utils";
import { Fragment, useRef } from "react";
import { Link } from "react-router-dom";
import { Icon } from "@/client/components/icon";
import { UserAvatar } from "@/client/components/user-avatar";
import { UserOptions } from "@/client/components/user-options";
import { useResumeStore } from "@/client/stores/resume";
import { BasicsSection } from "./sections/basics";
import { SectionBase } from "./sections/shared/section-base";
import { SectionIcon } from "./sections/shared/section-icon";
import { SummarySection } from "./sections/summary";
export const LeftSidebar = () => {
const containterRef = useRef<HTMLDivElement | null>(null);
const addSection = useResumeStore((state) => state.addSection);
const customSections = useResumeStore((state) => state.resume.data.sections.custom);
const scrollIntoView = (selector: string) => {
const section = containterRef.current?.querySelector(selector);
section?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
<Button asChild size="icon" variant="ghost" className="h-8 w-8 rounded-full">
<Link to="/dashboard">
<Icon size={14} />
</Link>
</Button>
<div className="flex flex-col items-center justify-center gap-y-2">
<SectionIcon id="basics" name="Basics" 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="awards" onClick={() => scrollIntoView("#awards")} />
<SectionIcon id="certifications" onClick={() => scrollIntoView("#certifications")} />
<SectionIcon id="interests" onClick={() => scrollIntoView("#interests")} />
<SectionIcon id="languages" onClick={() => scrollIntoView("#languages")} />
<SectionIcon id="volunteer" onClick={() => scrollIntoView("#volunteer")} />
<SectionIcon id="projects" onClick={() => scrollIntoView("#projects")} />
<SectionIcon id="publications" onClick={() => scrollIntoView("#publications")} />
<SectionIcon id="skills" onClick={() => scrollIntoView("#skills")} />
<SectionIcon id="references" onClick={() => scrollIntoView("#references")} />
<SectionIcon
id="custom"
variant="outline"
name="Add a new section"
icon={<Plus size={14} />}
onClick={() => {
addSection();
scrollIntoView("& > section:last-of-type");
}}
/>
</div>
<UserOptions>
<Button size="icon" variant="ghost" className="rounded-full">
<UserAvatar size={28} />
</Button>
</UserOptions>
</div>
<ScrollArea orientation="vertical" className="h-screen flex-1">
<div ref={containterRef} className="grid gap-y-6 p-6 @container/left">
<BasicsSection />
<Separator />
<SummarySection />
<Separator />
<SectionBase<Profile>
id="profiles"
title={(item) => item.network}
description={(item) => item.username}
/>
<Separator />
<SectionBase<Experience>
id="experience"
title={(item) => item.company}
description={(item) => item.position}
/>
<Separator />
<SectionBase<Education>
id="education"
title={(item) => item.institution}
description={(item) => item.area}
/>
<Separator />
<SectionBase<Award>
id="awards"
title={(item) => item.title}
description={(item) => item.awarder}
/>
<Separator />
<SectionBase<Certification>
id="certifications"
title={(item) => item.name}
description={(item) => item.issuer}
/>
<Separator />
<SectionBase<Interest>
id="interests"
title={(item) => item.name}
description={(item) => {
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
}}
/>
<Separator />
<SectionBase<Language>
id="languages"
title={(item) => item.name}
description={(item) => item.fluency || getCEFRLevel(item.fluencyLevel)}
/>
<Separator />
<SectionBase<Volunteer>
id="volunteer"
title={(item) => item.organization}
description={(item) => item.position}
/>
<Separator />
<SectionBase<Project>
id="projects"
title={(item) => item.name}
description={(item) => item.description}
/>
<Separator />
<SectionBase<Publication>
id="publications"
title={(item) => item.name}
description={(item) => item.publisher}
/>
<Separator />
<SectionBase<Skill>
id="skills"
title={(item) => item.name}
description={(item) => {
if (item.description) return item.description;
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
}}
/>
<Separator />
<SectionBase<Reference>
id="references"
title={(item) => item.name}
description={(item) => item.description}
/>
{/* Custom Sections */}
{Object.values(customSections).map((section) => (
<Fragment key={section.id}>
<Separator />
<SectionBase<CustomSectionItem>
id={`custom.${section.id}`}
title={(item) => item.name}
description={(item) => item.description}
/>
</Fragment>
))}
<Separator />
<Button size="lg" variant="outline" onClick={addSection}>
<PlusCircle />
<span className="ml-2">Add a new section</span>
</Button>
</div>
</ScrollArea>
</div>
);
};

View File

@ -0,0 +1,97 @@
import { basicsSchema } from "@reactive-resume/schema";
import { Input, Label } from "@reactive-resume/ui";
import { useResumeStore } from "@/client/stores/resume";
import { CustomFieldsSection } from "./custom/section";
import { PictureSection } from "./picture/section";
import { getSectionIcon } from "./shared/section-icon";
import { URLInput } from "./shared/url-input";
export const BasicsSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const basics = useResumeStore((state) => state.resume.data.basics);
return (
<section id="basics" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("basics")}
<h2 className="line-clamp-1 text-3xl font-bold">Basics</h2>
</div>
</header>
<main className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<PictureSection />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="basics.name">Full Name</Label>
<Input
id="basics.name"
placeholder="John Doe"
value={basics.name}
hasError={!basicsSchema.pick({ name: true }).safeParse({ name: basics.name }).success}
onChange={(event) => setValue("basics.name", event.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="basics.headline">Headline</Label>
<Input
id="basics.headline"
placeholder="Highly Creative Frontend Web Developer"
value={basics.headline}
onChange={(event) => setValue("basics.headline", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.email">Email Address</Label>
<Input
id="basics.email"
placeholder="john.doe@example.com"
value={basics.email}
hasError={
!basicsSchema.pick({ email: true }).safeParse({ email: basics.email }).success
}
onChange={(event) => setValue("basics.email", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.url">Website</Label>
<URLInput
id="basics.url"
value={basics.url}
placeholder="https://example.com"
onChange={(value) => setValue("basics.url", value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.phone">Phone Number</Label>
<Input
id="basics.phone"
placeholder="+1 (123) 4567 7890"
value={basics.phone}
onChange={(event) => setValue("basics.phone", event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="basics.location">Location</Label>
<Input
id="basics.location"
placeholder="105 Cedarhurst Ave, Cedarhurst, NY 11516"
value={basics.location}
onChange={(event) => setValue("basics.location", event.target.value)}
/>
</div>
<CustomFieldsSection className="col-span-2" />
</main>
</section>
);
};

View File

@ -0,0 +1,124 @@
import { createId } from "@paralleldrive/cuid2";
import { DotsSixVertical, Plus, X } from "@phosphor-icons/react";
import { CustomField as ICustomField } from "@reactive-resume/schema";
import { Button, Input } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, Reorder, useDragControls } from "framer-motion";
import { useResumeStore } from "@/client/stores/resume";
type CustomFieldProps = {
field: ICustomField;
onChange: (field: ICustomField) => void;
onRemove: (id: string) => void;
};
export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) => {
const controls = useDragControls();
const handleChange = (key: "name" | "value", value: string) =>
onChange({ ...field, [key]: value });
return (
<Reorder.Item
value={field}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
>
<div className="flex items-end justify-between space-x-4">
<Button
size="icon"
variant="ghost"
className="shrink-0"
onPointerDown={(event) => controls.start(event)}
>
<DotsSixVertical />
</Button>
<Input
placeholder="Name"
className="!ml-2"
value={field.name}
onChange={(event) => handleChange("name", event.target.value)}
/>
<Input
placeholder="Value"
value={field.value}
onChange={(event) => handleChange("value", event.target.value)}
/>
<Button
size="icon"
variant="ghost"
className="!ml-2 shrink-0"
onClick={() => onRemove(field.id)}
>
<X />
</Button>
</div>
</Reorder.Item>
);
};
type Props = {
className?: string;
};
export const CustomFieldsSection = ({ className }: Props) => {
const setValue = useResumeStore((state) => state.setValue);
const customFields = useResumeStore((state) => state.resume.data.basics.customFields);
const onAddCustomField = () => {
setValue("basics.customFields", [...customFields, { id: createId(), name: "", value: "" }]);
};
const onChangeCustomField = (field: ICustomField) => {
const index = customFields.findIndex((item) => item.id === field.id);
const newCustomFields = JSON.parse(JSON.stringify(customFields)) as ICustomField[];
newCustomFields[index] = field;
setValue("basics.customFields", newCustomFields);
};
const onReorderCustomFields = (values: ICustomField[]) => {
setValue("basics.customFields", values);
};
const onRemoveCustomField = (id: string) => {
setValue(
"basics.customFields",
customFields.filter((field) => field.id !== id),
);
};
return (
<div className={cn("space-y-4", className)}>
<AnimatePresence>
<Reorder.Group
axis="y"
className="space-y-4"
values={customFields}
onReorder={onReorderCustomFields}
>
{customFields.map((field) => (
<CustomField
field={field}
key={field.id}
onChange={onChangeCustomField}
onRemove={onRemoveCustomField}
/>
))}
</Reorder.Group>
</AnimatePresence>
<Button variant="link" onClick={onAddCustomField}>
<Plus className="mr-2" />
<span>Add a custom field</span>
</Button>
</div>
);
};

View File

@ -0,0 +1,219 @@
import {
AspectRatio,
Checkbox,
Input,
Label,
ToggleGroup,
ToggleGroupItem,
Tooltip,
} from "@reactive-resume/ui";
import { useMemo } from "react";
import { useResumeStore } from "@/client/stores/resume";
// Aspect Ratio Helpers
const stringToRatioMap = {
square: 1,
portrait: 0.75,
horizontal: 1.33,
} as const;
const ratioToStringMap = {
"1": "square",
"0.75": "portrait",
"1.33": "horizontal",
} as const;
type AspectRatio = keyof typeof stringToRatioMap;
// Border Radius Helpers
const stringToBorderRadiusMap = {
square: 0,
rounded: 6,
circle: 9999,
};
const borderRadiusToStringMap = {
"0": "square",
"6": "rounded",
"9999": "circle",
};
type BorderRadius = keyof typeof stringToBorderRadiusMap;
export const PictureOptions = () => {
const setValue = useResumeStore((state) => state.setValue);
const picture = useResumeStore((state) => state.resume.data.basics.picture);
const aspectRatio = useMemo(() => {
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;
return borderRadiusToStringMap[radius];
}, [picture.borderRadius]);
const onBorderRadiusChange = (value: BorderRadius) => {
if (!value) return;
setValue("basics.picture.borderRadius", stringToBorderRadiusMap[value]);
};
return (
<div className="flex flex-col gap-y-5">
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.size" className="col-span-1">
Size (in px)
</Label>
<Input
type="number"
id="picture.size"
placeholder="128"
value={picture.size}
className="col-span-2"
onChange={(event) => {
setValue("basics.picture.size", event.target.valueAsNumber);
}}
/>
</div>
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.aspectRatio" className="col-span-1">
Aspect Ratio
</Label>
<div className="col-span-2 flex items-center justify-between">
<ToggleGroup
type="single"
value={aspectRatio}
onValueChange={onAspectRatioChange}
className="flex items-center justify-center"
>
<Tooltip content="Square">
<ToggleGroupItem value="square">
<div className="h-3 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Horizontal">
<ToggleGroupItem value="horizontal">
<div className="h-2 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Portrait">
<ToggleGroupItem value="portrait">
<div className="h-3 w-2 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
</ToggleGroup>
<Input
min={0.1}
max={2}
step={0.05}
type="number"
className="w-[60px]"
id="picture.aspectRatio"
value={picture.aspectRatio}
onChange={(event) => {
setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0);
}}
/>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-6">
<Label htmlFor="picture.borderRadius" className="col-span-1">
Border Radius
</Label>
<div className="col-span-2 flex items-center justify-between">
<ToggleGroup
type="single"
value={borderRadius}
onValueChange={onBorderRadiusChange}
className="flex items-center justify-center"
>
<Tooltip content="Square">
<ToggleGroupItem value="square">
<div className="h-3 w-3 border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Rounded">
<ToggleGroupItem value="rounded">
<div className="h-3 w-3 rounded-sm border border-foreground" />
</ToggleGroupItem>
</Tooltip>
<Tooltip content="Circle">
<ToggleGroupItem value="circle">
<div className="h-3 w-3 rounded-full border border-foreground" />
</ToggleGroupItem>
</Tooltip>
</ToggleGroup>
<Input
min={0}
step={2}
max={9999}
type="number"
className="w-[60px]"
id="picture.borderRadius"
value={picture.borderRadius}
onChange={(event) => {
setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0);
}}
/>
</div>
</div>
<div>
<div className="grid grid-cols-3 items-start gap-x-6">
<div className="col-span-1">
<Label>Effects</Label>
</div>
<div className="col-span-2 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.hidden"
checked={picture.effects.hidden}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.hidden", checked);
}}
/>
<Label htmlFor="picture.effects.hidden">Hidden</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.border"
checked={picture.effects.border}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.border", checked);
}}
/>
<Label htmlFor="picture.effects.border">Border</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="picture.effects.grayscale"
checked={picture.effects.grayscale}
onCheckedChange={(checked) => {
setValue("basics.picture.effects.grayscale", checked);
}}
/>
<Label htmlFor="picture.effects.grayscale">Grayscale</Label>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,102 @@
import { Aperture, UploadSimple } from "@phosphor-icons/react";
import {
Avatar,
AvatarFallback,
AvatarImage,
buttonVariants,
Input,
Label,
Popover,
PopoverContent,
PopoverTrigger,
} from "@reactive-resume/ui";
import { cn, getInitials } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo, useRef } from "react";
import { z } from "zod";
import { useUploadImage } from "@/client/services/storage";
import { useResumeStore } from "@/client/stores/resume";
import { PictureOptions } from "./options";
export const PictureSection = () => {
const inputRef = useRef<HTMLInputElement>(null);
const { uploadImage, loading } = useUploadImage();
const setValue = useResumeStore((state) => state.setValue);
const name = useResumeStore((state) => state.resume.data.basics.name);
const picture = useResumeStore((state) => state.resume.data.basics.picture);
const isValidUrl = useMemo(() => z.string().url().safeParse(picture.url).success, [picture.url]);
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
const response = await uploadImage(file);
const url = response.data;
setValue("basics.picture.url", url);
}
};
return (
<div className="flex items-center gap-x-4">
<Avatar className="h-14 w-14">
{isValidUrl && <AvatarImage src={picture.url} />}
<AvatarFallback className="text-lg font-bold">{getInitials(name)}</AvatarFallback>
</Avatar>
<div className="flex w-full flex-col gap-y-1.5">
<Label htmlFor="basics.picture.url">Picture</Label>
<div className="flex items-center gap-x-2">
<Input
id="basics.picture.url"
placeholder="https://..."
value={picture.url}
onChange={(event) => setValue("basics.picture.url", event.target.value)}
/>
<AnimatePresence>
{/* Show options button if picture exists */}
{isValidUrl && (
<Popover>
<PopoverTrigger asChild>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
>
<Aperture />
</motion.button>
</PopoverTrigger>
<PopoverContent className="w-[360px]">
<PictureOptions />
</PopoverContent>
</Popover>
)}
{/* Show upload button if picture doesn't exist, else show remove button to delete picture */}
{!isValidUrl && (
<>
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
<motion.button
disabled={loading}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => inputRef.current?.click()}
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
>
<UploadSimple />
</motion.button>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,146 @@
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Plus } from "@phosphor-icons/react";
import { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import get from "lodash.get";
import { useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "./section-icon";
import { SectionListItem } from "./section-list-item";
import { SectionOptions } from "./section-options";
type Props<T extends SectionItem> = {
id: SectionKey;
title: (item: T) => string;
description?: (item: T) => string | undefined;
};
export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => {
const { open } = useDialog(id);
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore((state) =>
get(state.resume.data.sections, id),
) as SectionWithItem<T>;
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
if (!section) return null;
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (active.id !== over.id) {
const oldIndex = section.items.findIndex((item) => item.id === active.id);
const newIndex = section.items.findIndex((item) => item.id === over.id);
const sortedList = arrayMove(section.items as T[], oldIndex, newIndex);
setValue(`sections.${id}.items`, sortedList);
}
};
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);
setValue(`sections.${id}.items[${index}].visible`, !visible);
};
return (
<motion.section
id={id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid gap-y-6"
>
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon(id)}
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
</div>
<div className="flex items-center gap-x-2">
<SectionOptions id={id} />
</div>
</header>
<main className={cn("grid transition-opacity", !section.visible && "opacity-50")}>
{section.items.length === 0 && (
<Button
variant="outline"
onClick={onCreate}
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
>
<Plus size={14} />
<span className="font-medium">Add New {section.name}</span>
</Button>
)}
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
collisionDetection={closestCenter}
modifiers={[restrictToParentElement]}
>
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
<AnimatePresence>
{section.items.map((item, index) => (
<SectionListItem
id={item.id}
key={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)}
/>
))}
</AnimatePresence>
</SortableContext>
</DndContext>
</main>
{section.items.length > 0 && (
<footer className="flex items-center justify-end">
<Button variant="outline" className="ml-auto gap-x-2" onClick={onCreate}>
<Plus />
<span>Add New {section.name}</span>
</Button>
</footer>
)}
</motion.section>
);
};

View File

@ -0,0 +1,169 @@
import { createId } from "@paralleldrive/cuid2";
import { CopySimple, PencilSimple, Plus } from "@phosphor-icons/react";
import { SectionItem, SectionWithItem } from "@reactive-resume/schema";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
} from "@reactive-resume/ui";
import { produce } from "immer";
import get from "lodash.get";
import { useEffect, useMemo } from "react";
import { UseFormReturn } from "react-hook-form";
import { DialogName, useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
type Props<T extends SectionItem> = {
id: DialogName;
form: UseFormReturn<T>;
defaultValues: T;
children: React.ReactNode;
};
export const SectionDialog = <T extends SectionItem>({
id,
form,
defaultValues,
children,
}: Props<T>) => {
const { isOpen, mode, close, payload } = useDialog<T>(id);
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;
const name = useMemo(() => section?.name ?? "", [section?.name]);
const isCreate = mode === "create";
const isUpdate = mode === "update";
const isDelete = mode === "delete";
const isDuplicate = mode === "duplicate";
useEffect(() => {
if (isOpen) onReset();
}, [isOpen, payload]);
const onSubmit = async (values: T) => {
if (!section) return;
if (isCreate || isDuplicate) {
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
draft.push({ ...values, id: createId() });
}),
);
}
if (isUpdate) {
if (!payload.item?.id) return;
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
const index = draft.findIndex((item) => item.id === payload.item?.id);
if (index === -1) return;
draft[index] = values;
}),
);
}
if (isDelete) {
if (!payload.item?.id) return;
setValue(
`sections.${id}.items`,
produce(section.items, (draft: T[]): void => {
const index = draft.findIndex((item) => item.id === payload.item?.id);
if (index === -1) return;
draft.splice(index, 1);
}),
);
}
close();
};
const onReset = () => {
if (isCreate) form.reset({ ...defaultValues, id: createId() } as T);
if (isUpdate) form.reset({ ...defaultValues, ...payload.item });
if (isDuplicate) form.reset({ ...payload.item, id: createId() } as T);
if (isDelete) form.reset({ ...defaultValues, ...payload.item });
};
if (isDelete) {
return (
<AlertDialog open={isOpen} onOpenChange={close}>
<AlertDialogContent>
<Form {...form}>
<form>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this {name}?</AlertDialogTitle>
<AlertDialogDescription>
This action can be reverted by clicking on the undo button in the floating
toolbar.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={close}>
<DialogContent>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>
<div className="flex items-center space-x-2.5">
{isCreate && <Plus />}
{isUpdate && <PencilSimple />}
{isDuplicate && <CopySimple />}
<h2>
{isCreate && `Create a new ${name}`}
{isUpdate && `Update an existing ${name}`}
{isDuplicate && `Duplicate an existing ${name}`}
</h2>
</div>
</DialogTitle>
</DialogHeader>
{children}
<DialogFooter>
<Button type="submit">
{isCreate && "Create"}
{isUpdate && "Save Changes"}
{isDuplicate && "Duplicate"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,79 @@
import {
Article,
Books,
Briefcase,
Certificate,
CompassTool,
GameController,
GraduationCap,
HandHeart,
IconProps,
Medal,
PuzzlePiece,
ShareNetwork,
Translate,
User,
Users,
} from "@phosphor-icons/react";
import { defaultSection, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
import get from "lodash.get";
import { useResumeStore } from "@/client/stores/resume";
export const getSectionIcon = (id: SectionKey, props: IconProps = {}) => {
switch (id) {
// Left Sidebar
case "basics":
return <User size={18} {...props} />;
case "summary":
return <Article size={18} {...props} />;
case "awards":
return <Medal size={18} {...props} />;
case "profiles":
return <ShareNetwork size={18} {...props} />;
case "experience":
return <Briefcase size={18} {...props} />;
case "education":
return <GraduationCap size={18} {...props} />;
case "certifications":
return <Certificate size={18} {...props} />;
case "interests":
return <GameController size={18} {...props} />;
case "languages":
return <Translate size={18} {...props} />;
case "volunteer":
return <HandHeart size={18} {...props} />;
case "projects":
return <PuzzlePiece size={18} {...props} />;
case "publications":
return <Books size={18} {...props} />;
case "skills":
return <CompassTool size={18} {...props} />;
case "references":
return <Users size={18} {...props} />;
default:
return null;
}
};
type SectionIconProps = ButtonProps & {
id: SectionKey;
name?: string;
icon?: React.ReactNode;
};
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => {
const section = useResumeStore((state) =>
get(state.resume.data.sections, id, defaultSection),
) as SectionWithItem;
return (
<Tooltip side="right" content={name ?? section.name}>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
{icon ?? getSectionIcon(id, { size: 14 })}
</Button>
</Tooltip>
);
};

View File

@ -0,0 +1,103 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CopySimple, DotsSixVertical, PencilSimple, TrashSimple } from "@phosphor-icons/react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { motion } from "framer-motion";
export type SectionListItemProps = {
id: string;
title: string;
visible?: boolean;
description?: string;
// Callbacks
onUpdate?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
onToggleVisibility?: () => void;
};
export const SectionListItem = ({
id,
title,
description,
visible = true,
onUpdate,
onDuplicate,
onDelete,
onToggleVisibility,
}: SectionListItemProps) => {
const { setNodeRef, transform, transition, attributes, listeners, isDragging } = useSortable({
id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.5 : undefined,
zIndex: isDragging ? 100 : undefined,
transition,
};
return (
<motion.section
ref={setNodeRef}
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -50 }}
className="border-x border-t bg-secondary/10 first-of-type:rounded-t last-of-type:rounded-b last-of-type:border-b"
>
<div style={style} className="flex transition-opacity">
{/* Drag Handle */}
<div
{...listeners}
{...attributes}
className={cn(
"flex w-5 cursor-move items-center justify-center",
!isDragging && "hover:bg-secondary",
)}
>
<DotsSixVertical size={12} />
</div>
{/* List Item */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
"flex-1 cursor-context-menu p-4 hover:bg-secondary-accent",
!visible && "opacity-50",
)}
>
<h4 className="font-medium leading-relaxed">{title}</h4>
{description && <p className="text-xs leading-relaxed opacity-50">{description}</p>}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" side="left" sideOffset={-16}>
<DropdownMenuCheckboxItem checked={visible} onCheckedChange={onToggleVisibility}>
<span className="-ml-0.5">Visible</span>
</DropdownMenuCheckboxItem>
<DropdownMenuItem onClick={onUpdate}>
<PencilSimple size={14} />
<span className="ml-2">Edit</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<CopySimple size={14} />
<span className="ml-2">Copy</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-error" onClick={onDelete}>
<TrashSimple size={14} />
<span className="ml-2">Remove</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</motion.section>
);
};

View File

@ -0,0 +1,132 @@
import {
ArrowCounterClockwise,
Broom,
Columns,
DotsThreeVertical,
Eye,
EyeSlash,
PencilSimple,
Plus,
TrashSimple,
} from "@phosphor-icons/react";
import { defaultSections, SectionKey, SectionWithItem } from "@reactive-resume/schema";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Input,
} from "@reactive-resume/ui";
import get from "lodash.get";
import { useMemo } from "react";
import { useDialog } from "@/client/stores/dialog";
import { useResumeStore } from "@/client/stores/resume";
type Props = { id: SectionKey };
export const SectionOptions = ({ id }: Props) => {
const { open } = useDialog(id);
const setValue = useResumeStore((state) => state.setValue);
const removeSection = useResumeStore((state) => state.removeSection);
const originalName = get(defaultSections, `${id}.name`, "") as SectionWithItem;
const section = useResumeStore((state) => get(state.resume.data.sections, id)) as SectionWithItem;
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);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<DotsThreeVertical weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{hasItems && (
<>
<DropdownMenuItem onClick={onCreate}>
<Plus />
<span className="ml-2">Add a new item</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem onClick={toggleVisibility}>
{section.visible ? <Eye /> : <EyeSlash />}
<span className="ml-2">{section.visible ? "Hide" : "Show"}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PencilSimple />
<span className="ml-2">Rename</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<div className="relative col-span-2">
<Input
id={`sections.${id}.name`}
value={section.name}
onChange={(event) => {
setValue(`sections.${id}.name`, event.target.value);
}}
/>
<Button
size="icon"
variant="link"
onClick={onResetName}
className="absolute inset-y-0 right-0"
>
<ArrowCounterClockwise />
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Columns />
<span className="ml-2">Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={`${section.columns}`} onValueChange={onChangeColumns}>
<DropdownMenuRadioItem value="1">1 Column</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2">2 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3">3 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="4">4 Columns</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="5">5 Columns</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasItems} onClick={onResetItems}>
<Broom />
<span className="ml-2">Reset</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-error" disabled={!isCustomSection} onClick={onRemove}>
<TrashSimple />
<span className="ml-2">Remove</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,50 @@
import { Tag } from "@phosphor-icons/react";
import { URL, urlSchema } from "@reactive-resume/schema";
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
import { forwardRef, useMemo } from "react";
interface 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]);
return (
<>
<div className="flex gap-x-1">
<Input
id={id}
ref={ref}
value={value.href}
className="flex-1"
hasError={hasError}
placeholder={placeholder}
onChange={(event) => onChange({ ...value, href: event.target.value })}
/>
<Popover>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost">
<Tag />
</Button>
</PopoverTrigger>
<PopoverContent className="p-1.5">
<Input
value={value.label}
placeholder="Label"
onChange={(event) => onChange({ ...value, label: event.target.value })}
/>
</PopoverContent>
</Popover>
</div>
{hasError && <small className="opacity-75">URL must start with https://</small>}
</>
);
},
);

View File

@ -0,0 +1,41 @@
import { defaultSections } from "@reactive-resume/schema";
import { RichInput } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AiActions } from "@/client/components/ai-actions";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "./shared/section-icon";
import { SectionOptions } from "./shared/section-options";
export const SummarySection = () => {
const setValue = useResumeStore((state) => state.setValue);
const section = useResumeStore(
(state) => state.resume.data.sections.summary ?? defaultSections.summary,
);
return (
<section id="summary" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("summary")}
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
</div>
<div className="flex items-center gap-x-2">
<SectionOptions id="summary" />
</div>
</header>
<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} />
)}
/>
</main>
</section>
);
};

View File

@ -0,0 +1,72 @@
import { ScrollArea, Separator } from "@reactive-resume/ui";
import { useRef } from "react";
import { Copyright } from "@/client/components/copyright";
import { ThemeSwitch } from "@/client/components/theme-switch";
import { ExportSection } from "./sections/export";
import { InformationSection } from "./sections/information";
import { LayoutSection } from "./sections/layout";
import { PageSection } from "./sections/page";
import { SharingSection } from "./sections/sharing";
import { StatisticsSection } from "./sections/statistics";
import { TemplateSection } from "./sections/template";
import { ThemeSection } from "./sections/theme";
import { TypographySection } from "./sections/typography";
import { SectionIcon } from "./shared/section-icon";
export const RightSidebar = () => {
const containterRef = useRef<HTMLDivElement | null>(null);
const scrollIntoView = (selector: string) => {
const section = containterRef.current?.querySelector(selector);
section?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
<ScrollArea orientation="vertical" className="h-screen flex-1">
<div ref={containterRef} className="grid gap-y-6 p-6 @container/right">
<TemplateSection />
<Separator />
<LayoutSection />
<Separator />
<TypographySection />
<Separator />
<ThemeSection />
<Separator />
<PageSection />
<Separator />
<SharingSection />
<Separator />
<StatisticsSection />
<Separator />
<ExportSection />
<Separator />
<InformationSection />
<Separator />
<Copyright className="text-center" />
</div>
</ScrollArea>
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
<div />
<div className="flex flex-col items-center justify-center gap-y-2">
<SectionIcon id="template" name="Template" onClick={() => scrollIntoView("#template")} />
<SectionIcon id="layout" name="Layout" onClick={() => scrollIntoView("#layout")} />
<SectionIcon
id="typography"
name="Typography"
onClick={() => scrollIntoView("#typography")}
/>
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
<SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} />
</div>
<ThemeSwitch size={14} />
</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
import { CircleNotch, FileJs, FilePdf } from "@phosphor-icons/react";
import { buttonVariants, Card, CardContent, CardDescription, CardTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { saveAs } from "file-saver";
import { useToast } from "@/client/hooks/use-toast";
import { usePrintResume } from "@/client/services/resume/print";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ExportSection = () => {
const { toast } = useToast();
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);
toast({
variant: "success",
title: "A JSON snapshot of your resume has been successfully exported.",
});
};
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);
};
return (
<section id="export" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("export")}
<h2 className="line-clamp-1 text-3xl font-bold">Export</h2>
</div>
</header>
<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",
)}
>
<FileJs size={22} />
<CardContent className="flex-1">
<CardTitle className="text-sm">JSON</CardTitle>
<CardDescription className="font-normal">
Download a JSON snapshot of your resume. This file can be used to import your resume
in the future, or can even be shared with others to collaborate.
</CardDescription>
</CardContent>
</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",
)}
>
{loading ? <CircleNotch size={22} className="animate-spin" /> : <FilePdf size={22} />}
<CardContent className="flex-1">
<CardTitle className="text-sm">PDF</CardTitle>
<CardDescription className="font-normal">
Download a PDF of your resume. This file can be used to print your resume, send it to
recruiters, or upload on job portals.
</CardDescription>
</CardContent>
</Card>
</main>
</section>
);
};

View File

@ -0,0 +1,129 @@
import { Book, EnvelopeSimpleOpen, GithubLogo, HandHeart } from "@phosphor-icons/react";
import {
buttonVariants,
Card,
CardContent,
CardDescription,
CardFooter,
CardTitle,
} from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { getSectionIcon } from "../shared/section-icon";
const DonateCard = () => (
<Card className="space-y-4 bg-info text-info-foreground">
<CardContent className="space-y-2">
<CardTitle>Support the app by donating what you can!</CardTitle>
<CardDescription className="space-y-2">
<p>
I built Reactive Resume mostly by myself during my spare time, with a lot of help from
other great open-source contributors.
</p>
<p>
If you like the app and want to support keeping it free forever, please donate whatever
you can afford to give.
</p>
</CardDescription>
</CardContent>
<CardFooter>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/sponsors/AmruthPillai"
target="_blank"
rel="noopener noreferrer nofollow"
>
<HandHeart size={14} weight="bold" className="mr-2" />
<span>Donate to Reactive Resume</span>
</a>
</CardFooter>
</Card>
);
const IssuesCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Found a bug, or have an idea for a new feature?</CardTitle>
<CardDescription className="space-y-2">
<p>I'm sure the app is not perfect, but I'd like for it to be.</p>
<p>
If you faced any issues while creating your resume, or have an idea that would help you
and other users in creating your resume more easily, drop an issue on the repository or
send me an email about it.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://github.com/AmruthPillai/Reactive-Resume/issues/new/choose"
target="_blank"
rel="noopener noreferrer nofollow"
>
<GithubLogo size={14} weight="bold" className="mr-2" />
<span>Raise an issue</span>
</a>
<a
className={cn(buttonVariants({ size: "sm" }))}
href="mailto:hello@amruthpillai.com"
target="_blank"
rel="noopener noreferrer nofollow"
>
<EnvelopeSimpleOpen size={14} weight="bold" className="mr-2" />
<span>Send me a message</span>
</a>
</CardFooter>
</Card>
);
const DocumentationCard = () => (
<Card className="space-y-4">
<CardContent className="space-y-2">
<CardTitle>Don't know where to begin? Hit the docs!</CardTitle>
<CardDescription className="space-y-2">
<p>
The community has spent a lot of time writing the documentation for Reactive Resume, and
I'm sure it will help you get started with the app.
</p>
<p>
There are also a lot of examples to help you get started, and features that you might not
know about which could help you build your perfect resume.
</p>
</CardDescription>
</CardContent>
<CardFooter className="space-x-4">
<a
className={cn(buttonVariants({ size: "sm" }))}
href="https://docs.rxresu.me/"
target="_blank"
rel="noopener noreferrer nofollow"
>
<Book size={14} weight="bold" className="mr-2" />
<span>Documentation</span>
</a>
</CardFooter>
</Card>
);
export const InformationSection = () => {
return (
<section id="information" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("information")}
<h2 className="line-clamp-1 text-3xl font-bold">Information</h2>
</div>
</header>
<main className="grid gap-y-4">
<DonateCard />
<DocumentationCard />
<IssuesCard />
</main>
</section>
);
};

View File

@ -0,0 +1,269 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ArrowCounterClockwise, DotsSixVertical, Plus, TrashSimple } from "@phosphor-icons/react";
import { defaultMetadata } from "@reactive-resume/schema";
import { Button, Portal, Tooltip } from "@reactive-resume/ui";
import {
cn,
LayoutLocator,
moveItemInLayout,
parseLayoutLocator,
SortablePayload,
} from "@reactive-resume/utils";
import get from "lodash.get";
import { useState } from "react";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
type ColumnProps = {
id: string;
name: string;
items: string[];
};
const Column = ({ id, name, items }: ColumnProps) => {
const { setNodeRef } = useDroppable({ id });
return (
<SortableContext id={id} items={items} strategy={verticalListSortingStrategy}>
<div className="relative">
<div className="absolute inset-0 w-3/4 rounded bg-secondary/50" />
<div className="relative z-10 p-3 pb-8">
<p className="mb-3 text-xs font-bold">{name}</p>
<div ref={setNodeRef} className="space-y-3">
{items.map((section) => (
<SortableSection key={section} id={section} />
))}
</div>
</div>
</div>
</SortableContext>
);
};
type SortableSectionProps = {
id: string;
};
const SortableSection = ({ id }: SortableSectionProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transition,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
<Section id={id} />
</div>
);
};
type SectionProps = {
id: string;
isDragging?: boolean;
};
const Section = ({ id, isDragging = false }: SectionProps) => {
const name = useResumeStore((state) =>
get(state.resume.data.sections, `${id}.name`, id),
) as string;
return (
<div
className={cn(
"cursor-grab rounded bg-primary p-2 text-primary-foreground transition-colors hover:bg-primary-accent",
isDragging && "cursor-grabbing",
)}
>
<div className="flex items-center gap-x-2">
<DotsSixVertical size={12} weight="bold" />
<p className="flex-1 truncate text-xs font-medium">{name}</p>
</div>
</div>
);
};
export const LayoutSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const layout = useResumeStore((state) => state.resume.data.metadata.layout);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onDragStart = ({ active }: DragStartEvent) => {
setActiveId(active.id as string);
};
const onDragCancel = () => {
setActiveId(null);
};
const onDragEvent = ({ active, over }: DragOverEvent | DragEndEvent) => {
if (!over || !active.data.current) return;
const currentPayload = active.data.current.sortable as SortablePayload | null;
const current = parseLayoutLocator(currentPayload);
if (active.id === over.id) return;
if (!over.data.current) {
const [page, column] = (over.id as string).split(".").map(Number);
const target = { page, column, section: 0 } as LayoutLocator;
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
return;
}
const targetPayload = over.data.current.sortable as SortablePayload | null;
const target = parseLayoutLocator(targetPayload);
const newLayout = moveItemInLayout(current, target, layout);
setValue("metadata.layout", newLayout);
};
const onDragEnd = (event: DragEndEvent) => {
onDragEvent(event);
setActiveId(null);
};
const onAddPage = () => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy.push([[], []]);
setValue("metadata.layout", layoutCopy);
};
const onRemovePage = (page: number) => {
const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][];
layoutCopy[0][0].push(...layoutCopy[page][0]); // Main
layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar
layoutCopy.splice(page, 1);
setValue("metadata.layout", layoutCopy);
};
const onResetLayout = () => {
const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][];
// 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) => {
customSections.push(...column.filter((section) => section.startsWith("custom.")));
});
});
if (customSections.length > 0) layoutCopy[0][0].push(...customSections);
setValue("metadata.layout", layoutCopy);
};
return (
<section id="layout" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("layout")}
<h2 className="line-clamp-1 text-3xl font-bold">Layout</h2>
</div>
<Tooltip content="Reset Layout">
<Button size="icon" variant="ghost" onClick={onResetLayout}>
<ArrowCounterClockwise />
</Button>
</Tooltip>
</header>
<main className="grid gap-y-4">
{/* Pages */}
<DndContext
sensors={sensors}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
{layout.map((page, pageIndex) => {
const mainIndex = `${pageIndex}.0`;
const sidebarIndex = `${pageIndex}.1`;
const main = page[0];
const sidebar = page[1];
return (
<div key={pageIndex} className="rounded border p-3 pb-4">
<div className="flex items-center justify-between">
<p className="mb-3 text-xs font-bold">Page {pageIndex + 1}</p>
{pageIndex !== 0 && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => onRemovePage(pageIndex)}
>
<TrashSimple size={12} />
</Button>
)}
</div>
<div className="grid grid-cols-2 items-start gap-x-4">
<Column id={mainIndex} name="Main" items={main} />
<Column id={sidebarIndex} name="Sidebar" items={sidebar} />
</div>
</div>
);
})}
<Portal>
<DragOverlay>{activeId && <Section id={activeId} isDragging />}</DragOverlay>
</Portal>
</DndContext>
<Button variant="outline" className="ml-auto" onClick={onAddPage}>
<Plus />
<span className="ml-2">Add New Page</span>
</Button>
</main>
</section>
);
};

View File

@ -0,0 +1,97 @@
import {
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Slider,
Switch,
} from "@reactive-resume/ui";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const PageSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const page = useResumeStore((state) => state.resume.data.metadata.page);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("page")}
<h2 className="line-clamp-1 text-3xl font-bold">Page</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<Label>Format</Label>
<Select
value={page.format}
onValueChange={(value) => {
setValue("metadata.page.format", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a4">A4</SelectItem>
<SelectItem value="letter">Letter</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Margin</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={48}
step={2}
value={[page.margin]}
onValueChange={(value) => {
setValue("metadata.page.margin", value[0]);
}}
/>
<span className="text-base font-bold">{page.margin}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.breakLine"
checked={page.options.breakLine}
onCheckedChange={(checked) => {
setValue("metadata.page.options.breakLine", checked);
}}
/>
<Label htmlFor="metadata.page.options.breakLine">Show Break Line</Label>
</div>
</div>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.page.options.pageNumbers"
checked={page.options.pageNumbers}
onCheckedChange={(checked) => {
setValue("metadata.page.options.pageNumbers", checked);
}}
/>
<Label htmlFor="metadata.page.options.pageNumbers">Show Page Numbers</Label>
</div>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,90 @@
import { CopySimple } from "@phosphor-icons/react";
import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui";
import { AnimatePresence, motion } from "framer-motion";
import { useToast } from "@/client/hooks/use-toast";
import { useUser } from "@/client/services/user";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const SharingSection = () => {
const { user } = useUser();
const { toast } = useToast();
const username = user?.username;
const setValue = useResumeStore((state) => state.setValue);
const slug = useResumeStore((state) => state.resume.slug);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
// Constants
const url = `${window.location.origin}/${username}/${slug}`;
const onCopy = async () => {
await navigator.clipboard.writeText(url);
toast({
variant: "success",
title: "A link has been copied to your clipboard.",
description:
"Anyone with this link can view and download the resume. Share it on your profile or with recruiters.",
});
};
return (
<section id="sharing" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("sharing")}
<h2 className="line-clamp-1 text-3xl font-bold">Sharing</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="space-y-1.5">
<div className="flex items-center gap-x-4">
<Switch
id="visibility"
checked={isPublic}
onCheckedChange={(checked) => {
setValue("visibility", checked ? "public" : "private");
}}
/>
<div>
<Label htmlFor="visibility" className="space-y-1">
<p>Public</p>
<p className="text-xs opacity-60">
Anyone with the link can view and download the resume.
</p>
</Label>
</div>
</div>
</div>
<AnimatePresence presenceAffectsLayout>
{isPublic && (
<motion.div
layout
className="space-y-1.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Label htmlFor="resume-url">URL</Label>
<div className="flex gap-x-1.5">
<Input id="resume-url" readOnly value={url} className="flex-1" />
<Tooltip content="Copy to Clipboard">
<Button size="icon" variant="ghost" onClick={onCopy}>
<CopySimple />
</Button>
</Tooltip>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</section>
);
};

View File

@ -0,0 +1,65 @@
import { Info } from "@phosphor-icons/react";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useResumeStatistics } from "@/client/services/resume";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const StatisticsSection = () => {
const id = useResumeStore((state) => state.resume.id);
const isPublic = useResumeStore((state) => state.resume.visibility === "public");
const { statistics } = useResumeStatistics(id, isPublic);
return (
<section id="statistics" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("statistics")}
<h2 className="line-clamp-1 text-3xl font-bold">Statistics</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
<AnimatePresence>
{!isPublic && (
<motion.div
className="col-span-2"
initial={{ opacity: 0, y: -50, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -50, filter: "blur(10px)" }}
>
<Alert variant="info">
<Info size={18} />
<AlertTitle>Statistics are available only for public resumes.</AlertTitle>
<AlertDescription className="text-xs leading-relaxed">
You can track the number of views your resume has received, or how many people
have downloaded the resume by enabling public sharing.
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.views ?? 0}
</h3>
<p className="opacity-75">Views</p>
</div>
<div>
<h3 className={cn("text-4xl font-bold blur-none transition-all", !isPublic && "blur-sm")}>
{statistics?.downloads ?? 0}
</h3>
<p className="opacity-75">Downloads</p>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,42 @@
import { Button } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const TemplateSection = () => {
// TODO: Import templates from @reactive-resume/templates
const templateList = ["rhyhorn"];
const setValue = useResumeStore((state) => state.setValue);
const currentTemplate = useResumeStore((state) => state.resume.data.metadata.template);
return (
<section id="template" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("template")}
<h2 className="line-clamp-1 text-3xl font-bold">Template</h2>
</div>
</header>
<main className="grid grid-cols-2 gap-y-4">
{templateList.map((template) => (
<Button
key={template}
variant="outline"
disabled={template === currentTemplate}
onClick={() => setValue("metadata.template", template)}
className={cn(
"flex h-12 items-center justify-center overflow-hidden rounded border text-center text-sm capitalize ring-primary transition-colors hover:bg-secondary-accent focus:outline-none focus:ring-1 disabled:opacity-100",
template === currentTemplate && "ring-1",
)}
>
{template}
</Button>
))}
</main>
</section>
);
};

View File

@ -0,0 +1,133 @@
import { Input, Label, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { HexColorPicker } from "react-colorful";
import { colors } from "@/client/constants/colors";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
export const ThemeSection = () => {
const setValue = useResumeStore((state) => state.setValue);
const theme = useResumeStore((state) => state.resume.data.metadata.theme);
return (
<section id="theme" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("theme")}
<h2 className="line-clamp-1 text-3xl font-bold">Theme</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="mb-2 grid grid-cols-6 flex-wrap justify-items-center gap-y-4 @xs/right:grid-cols-9">
{colors.map((color) => (
<div
key={color}
onClick={() => {
setValue("metadata.theme.primary", color);
}}
className={cn(
"flex h-6 w-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",
)}
>
<div className="h-5 w-5 rounded-full" style={{ backgroundColor: color }} />
</div>
))}
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Primary Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.primary }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.primary}
onChange={(color) => {
setValue("metadata.theme.primary", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.primary"
value={theme.primary}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.primary", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Background Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.background }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.background}
onChange={(color) => {
setValue("metadata.theme.background", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.background"
value={theme.background}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.background", event.target.value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme.primary">Text Color</Label>
<div className="relative">
<Popover>
<PopoverTrigger asChild>
<div
className="absolute inset-y-0 left-3 my-2.5 h-4 w-4 cursor-pointer rounded-full ring-primary ring-offset-2 ring-offset-background transition-shadow hover:ring-1"
style={{ backgroundColor: theme.text }}
/>
</PopoverTrigger>
<PopoverContent asChild className="rounded-lg border-none bg-transparent p-0">
<HexColorPicker
color={theme.text}
onChange={(color) => {
setValue("metadata.theme.text", color);
}}
/>
</PopoverContent>
</Popover>
<Input
id="theme.text"
value={theme.text}
className="pl-10"
onChange={(event) => {
setValue("metadata.theme.text", event.target.value);
}}
/>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,183 @@
import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui";
import { cn } from "@reactive-resume/utils";
import { fonts } from "@reactive-resume/utils";
import { useCallback, useEffect, useState } from "react";
import webfontloader from "webfontloader";
import { useResumeStore } from "@/client/stores/resume";
import { getSectionIcon } from "../shared/section-icon";
const fontSuggestions = [
"Open Sans",
"Merriweather",
"CMU Serif",
"Playfair Display",
"Lato",
"Lora",
"PT Sans",
"PT Serif",
"IBM Plex Sans",
"IBM Plex Serif",
];
const families: ComboboxOption[] = fonts.map((font) => ({
value: font.family,
label: font.family,
}));
export const TypographySection = () => {
const [subsets, setSubsets] = useState<ComboboxOption[]>([]);
const [variants, setVariants] = useState<ComboboxOption[]>([]);
const setValue = useResumeStore((state) => state.setValue);
const typography = useResumeStore((state) => state.resume.data.metadata.typography);
const loadFontSuggestions = useCallback(async () => {
fontSuggestions.forEach((font) => {
if (font === "CMU Serif") return;
webfontloader.load({
events: false,
classes: false,
google: { families: [font], text: font },
});
});
}, [fontSuggestions]);
useEffect(() => {
loadFontSuggestions();
}, []);
useEffect(() => {
const subsets = fonts.find((font) => font.family === typography.font.family)?.subsets ?? [];
setSubsets(subsets.map((subset) => ({ value: subset, label: subset })));
const variants = fonts.find((font) => font.family === typography.font.family)?.variants ?? [];
setVariants(variants.map((variant) => ({ value: variant, label: variant })));
}, [typography.font.family]);
return (
<section id="typography" className="grid gap-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-x-4">
{getSectionIcon("typography")}
<h2 className="line-clamp-1 text-3xl font-bold">Typography</h2>
</div>
</header>
<main className="grid gap-y-4">
<div className="grid grid-cols-2 gap-4">
{fontSuggestions.map((font) => (
<Button
key={font}
variant="outline"
style={{ fontFamily: font }}
disabled={typography.font.family === font}
onClick={() => {
setValue("metadata.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",
)}
>
{font}
</Button>
))}
</div>
<div className="space-y-1.5">
<Label>Font Family</Label>
<Combobox
options={families}
value={typography.font.family}
searchPlaceholder="Search for a font family"
onValueChange={(value) => {
setValue("metadata.typography.font.family", value);
setValue("metadata.typography.font.subset", "latin");
setValue("metadata.typography.font.variants", ["regular"]);
}}
/>
</div>
<div className="grid grid-cols-2 gap-x-4">
<div className="space-y-1.5">
<Label>Font Subset</Label>
<Combobox
options={subsets}
value={typography.font.subset}
searchPlaceholder="Search for a font subset"
onValueChange={(value) => {
setValue("metadata.typography.font.subset", value);
}}
/>
</div>
<div className="space-y-1.5">
<Label>Font Variants</Label>
<Combobox
multiple
options={variants}
value={typography.font.variants}
searchPlaceholder="Search for a font variant"
onValueChange={(value) => {
setValue("metadata.typography.font.variants", value);
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Font Size</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={12}
max={18}
step={1}
value={[typography.font.size]}
onValueChange={(value) => {
setValue("metadata.typography.font.size", value[0]);
}}
/>
<span className="text-base font-bold">{typography.font.size}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Line Height</Label>
<div className="flex items-center gap-x-4 py-1">
<Slider
min={0}
max={3}
step={0.25}
value={[typography.lineHeight]}
onValueChange={(value) => {
setValue("metadata.typography.lineHeight", value[0]);
}}
/>
<span className="text-base font-bold">{typography.lineHeight}</span>
</div>
</div>
<div className="space-y-1.5">
<Label>Options</Label>
<div className="py-2">
<div className="flex items-center gap-x-4">
<Switch
id="metadata.typography.underlineLinks"
checked={typography.underlineLinks}
onCheckedChange={(checked) => {
setValue("metadata.typography.underlineLinks", checked);
}}
/>
<Label htmlFor="metadata.typography.underlineLinks">Underline Links</Label>
</div>
</div>
</div>
</main>
</section>
);
};

View File

@ -0,0 +1,65 @@
import {
DiamondsFour,
DownloadSimple,
IconProps,
Info,
Layout,
Palette,
ReadCvLogo,
ShareFat,
TextT,
TrendUp,
} from "@phosphor-icons/react";
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
export type MetadataKey =
| "template"
| "layout"
| "typography"
| "theme"
| "page"
| "sharing"
| "statistics"
| "export"
| "information";
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
switch (id) {
// Left Sidebar
case "template":
return <DiamondsFour size={18} {...props} />;
case "layout":
return <Layout size={18} {...props} />;
case "typography":
return <TextT size={18} {...props} />;
case "theme":
return <Palette size={18} {...props} />;
case "page":
return <ReadCvLogo size={18} {...props} />;
case "sharing":
return <ShareFat size={18} {...props} />;
case "statistics":
return <TrendUp size={18} {...props} />;
case "export":
return <DownloadSimple size={18} {...props} />;
case "information":
return <Info size={18} {...props} />;
default:
return null;
}
};
type SectionIconProps = ButtonProps & {
id: MetadataKey;
name: string;
icon?: React.ReactNode;
};
export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => (
<Tooltip side="left" content={name}>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
{icon ?? getSectionIcon(id, { size: 14 })}
</Button>
</Tooltip>
);