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