mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-22 12:41:31 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
112
apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx
Normal file
112
apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
112
apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx
Normal file
112
apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
160
apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx
Normal file
160
apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
133
apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx
Normal file
133
apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
193
apps/client/src/pages/builder/sidebars/left/index.tsx
Normal file
193
apps/client/src/pages/builder/sidebars/left/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user