refactor: ponytail audit

This commit is contained in:
Amruth Pillai
2026-07-27 20:26:16 +02:00
parent bb1fb3a7d6
commit 9110e86997
157 changed files with 1082 additions and 2177 deletions
+2 -2
View File
@@ -176,7 +176,7 @@ export function ChipInput({
});
if (nextValues.length === 0) return;
const newChips = Array.from(new Set([...chips, ...nextValues]));
const newChips = [...new Set([...chips, ...nextValues])];
setChips(newChips);
},
[chips, setChips],
@@ -269,7 +269,7 @@ export function ChipInput({
const oldIndex = chips.indexOf(active.id as string);
const newIndex = chips.indexOf(over.id as string);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = Array.from(chips);
const newOrder = [...chips];
const [removed] = newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, removed);
handleReorder(newOrder);
+8 -22
View File
@@ -3,7 +3,7 @@ import type { CellComponentProps } from "react-window";
import { t } from "@lingui/core/macro";
import { ProhibitIcon } from "@phosphor-icons/react";
import Fuse from "fuse.js";
import { memo, useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Grid } from "react-window";
import { icons } from "@reactive-resume/schema/icons";
import { Button } from "@reactive-resume/ui/components/button";
@@ -14,6 +14,7 @@ import { cn } from "@reactive-resume/utils/style";
const columnCount = 8;
const columnWidth = 36;
const rowHeight = 36;
const iconSearch = new Fuse(icons, { threshold: 0.35 });
type IconSearchInputProps = {
value: string;
@@ -21,7 +22,7 @@ type IconSearchInputProps = {
className?: string;
};
function _IconSearchInput(props: IconSearchInputProps) {
function IconSearchInput(props: IconSearchInputProps) {
return (
<Input
spellCheck={false}
@@ -41,10 +42,6 @@ function _IconSearchInput(props: IconSearchInputProps) {
);
}
const IconSearchInput = memo(_IconSearchInput);
IconSearchInput.displayName = "IconSearchInput";
type IconCellComponentProps = CellComponentProps & {
icons: IconName[];
onChange: (icon: IconName) => void;
@@ -70,18 +67,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
);
}
function useIconSearch() {
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
const search = useCallback(
(query: string): IconName[] => {
if (!query.trim()) return Array.from(icons);
return fuse.search(query).map((result) => result.item);
},
[fuse],
);
return search;
function searchIcons(query: string): IconName[] {
if (!query.trim()) return [...icons];
return iconSearch.search(query).map((result) => result.item);
}
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
@@ -91,12 +79,10 @@ type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onCh
};
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
const searchIcons = useIconSearch();
const [search, setSearch] = useState("");
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
const searchedIcons = useMemo(() => searchIcons(search), [search]);
const rowCount = Math.ceil(searchedIcons.length / columnCount);
return (
<Popover {...popoverProps}>
@@ -6,7 +6,7 @@ export function getNextWeights(fontFamily: string): Weight[] | null {
const fontData = getFont(fontFamily);
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
const uniqueWeights = [...new Set(fontData.weights)] as Weight[];
// Try to pick 400 and 600 if available
const weights: Weight[] = [];
+1 -1
View File
@@ -284,7 +284,7 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
const toastId = toast.loading(t`Duplicating your resume...`);
duplicateResume(value, {
onSuccess: async (id) => {
onSuccess: (id) => {
toast.success(t`Your resume has been duplicated successfully.`, { id: toastId });
closeDialog();
+8 -33
View File
@@ -7,8 +7,6 @@ import { awardItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -38,7 +36,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "awards", value, data?.customSectionId);
});
@@ -70,7 +68,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "awards", value, data?.customSectionId);
});
@@ -127,21 +125,9 @@ const AwardForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -163,20 +149,9 @@ const AwardForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "certifications", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "certifications", value, data?.customSectionId);
});
@@ -107,21 +105,9 @@ const CertificationForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +129,9 @@ const CertificationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -31,7 +31,7 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -67,7 +67,7 @@ export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -88,7 +88,7 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
items: data?.items ?? [],
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
draft.customSections.push(value);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
@@ -146,7 +146,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
icon: data.icon ?? "",
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === value.id);
if (index === -1) return;
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -40,7 +38,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "education", value, data?.customSectionId);
});
@@ -72,7 +70,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "education", value, data?.customSectionId);
});
@@ -116,24 +114,15 @@ const EducationForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -155,20 +144,9 @@ const EducationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -12,7 +12,6 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { generateId } from "@reactive-resume/utils/string";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -44,7 +43,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "experience", value, data?.customSectionId);
});
@@ -76,7 +75,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "experience", value, data?.customSectionId);
});
@@ -122,24 +121,15 @@ const ExperienceForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -219,20 +209,9 @@ const ExperienceForm = withForm({
{/* Single Role Description — only show when no roles are defined */}
{!hasRoles && (
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
)}
</>
);
@@ -39,7 +39,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "interests", value, data?.customSectionId);
});
@@ -71,7 +71,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "interests", value, data?.customSectionId);
});
@@ -34,7 +34,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "languages", value, data?.customSectionId);
});
@@ -66,7 +66,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "languages", value, data?.customSectionId);
});
@@ -17,7 +17,6 @@ import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -47,7 +46,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "profiles", value, data?.customSectionId);
});
@@ -79,7 +78,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "profiles", value, data?.customSectionId);
});
@@ -211,24 +210,15 @@ const ProfileForm = withForm({
)}
</form.Field>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { projectItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -36,7 +34,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "projects", value, data?.customSectionId);
});
@@ -68,7 +66,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "projects", value, data?.customSectionId);
});
@@ -104,24 +102,15 @@ const ProjectForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +132,9 @@ const ProjectForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "publications", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "publications", value, data?.customSectionId);
});
@@ -109,21 +107,9 @@ const PublicationForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -145,20 +131,9 @@ const PublicationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "references", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "references", value, data?.customSectionId);
});
@@ -107,21 +105,9 @@ const ReferenceForm = withForm({
<form.AppField name="phone">{(field) => <field.TextField label={<Trans>Phone</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +129,9 @@ const ReferenceForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -43,7 +43,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "skills", value, data?.customSectionId);
});
@@ -75,7 +75,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "skills", value, data?.customSectionId);
});
@@ -30,7 +30,7 @@ export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.s
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -66,7 +66,7 @@ export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.s
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeStore((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "volunteer", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "volunteer", value, data?.customSectionId);
});
@@ -109,21 +107,9 @@ const VolunteerForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -145,20 +131,9 @@ const VolunteerForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -40,7 +40,7 @@ const stageOf = (status: ApplicationStatus) => STAGES.find((s) => s.value === st
const dateInputValue = (value: Date | string) => {
const date = new Date(value);
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
};
const formatDate = (value: Date | string) =>
@@ -27,10 +27,7 @@ import { FileAttachmentField } from "./file-attachment-field";
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
const todayInputValue = () => {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
};
const todayInputValue = () => new Date().toISOString().slice(0, 10);
const emptyForm = () => ({
company: "",
@@ -4,7 +4,6 @@ import { ORPCError } from "@orpc/client";
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { useToggle } from "usehooks-ts";
import z from "zod";
@@ -29,16 +28,13 @@ export function ResumePasswordPage({ redirectPath }: Props) {
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
const [username, slug] = useMemo(() => {
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
if (!username || !slug) throw navigate({ to: "/" });
return [username, slug];
}, [redirectPath, navigate]);
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
if (!username || !slug) throw navigate({ to: "/" });
const form = useAppForm({
defaultValues: { password: "" },
validators: { onSubmit: formSchema },
onSubmit: async ({ value, formApi }) => {
onSubmit: ({ value, formApi }) => {
const toastId = toast.loading(t`Verifying password...`);
verifyPassword(
@@ -1,111 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
code: z.string().trim(),
});
export function VerifyTwoFactorBackupPage() {
const router = useRouter();
const navigate = useNavigate();
const form = useAppForm({
defaultValues: { code: "" },
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
const toastId = toast.loading(t`Verifying backup code...`);
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
if (error) {
toast.error(
error.message ||
t({
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
message: "Failed to verify your backup code. Please try again.",
}),
{ id: toastId },
);
return;
}
toast.dismiss(toastId);
await router.invalidate();
void navigate({ to: "/dashboard", replace: true });
},
});
return (
<>
<div className="space-y-1 text-center">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Verify with a Backup Code</Trans>
</h1>
<div className="text-muted-foreground">
<Trans>Enter one of your saved backup codes to access your account</Trans>
</div>
</div>
<form
className="grid gap-6"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="code">
{(field) => (
<FormItem
className="justify-self-center"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<Input
maxLength={10}
className="max-w-xs"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<div className="flex gap-x-2">
<Button
variant="outline"
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/verify-2fa">
<ArrowLeftIcon />
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
<Trans comment="Primary action button to submit backup code">Verify</Trans>
</Button>
</div>
</form>
</>
);
}
+66 -31
View File
@@ -10,31 +10,44 @@ import { Input } from "@reactive-resume/ui/components/input";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
const totpSchema = z.object({
code: z.string().length(6, "Code must be 6 digits"),
});
export function VerifyTwoFactorPage() {
const backupCodeSchema = z.object({
code: z.string().trim(),
});
type TwoFactorVerificationPageProps = {
backupCode?: boolean;
};
function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerificationPageProps) {
const router = useRouter();
const navigate = useNavigate();
const form = useAppForm({
defaultValues: { code: "" },
validators: { onSubmit: formSchema },
validators: { onSubmit: backupCode ? backupCodeSchema : totpSchema },
onSubmit: async ({ value }) => {
const toastId = toast.loading(t`Verifying code...`);
const { error } = await authClient.twoFactor.verifyTotp({
code: value.code,
});
const toastId = toast.loading(backupCode ? t`Verifying backup code...` : t`Verifying code...`);
const code = backupCode ? `${value.code.slice(0, 5)}-${value.code.slice(5)}` : value.code;
const { error } = backupCode
? await authClient.twoFactor.verifyBackupCode({ code })
: await authClient.twoFactor.verifyTotp({ code });
if (error) {
toast.error(
error.message ||
t({
comment: "Fallback toast when verifying a two-factor authentication code fails",
message: "Failed to verify your code. Please try again.",
}),
(backupCode
? t({
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
message: "Failed to verify your backup code. Please try again.",
})
: t({
comment: "Fallback toast when verifying a two-factor authentication code fails",
message: "Failed to verify your code. Please try again.",
})),
{ id: toastId },
);
return;
@@ -50,10 +63,14 @@ export function VerifyTwoFactorPage() {
<>
<div className="space-y-1 text-center">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Two-Factor Authentication</Trans>
{backupCode ? <Trans>Verify with a Backup Code</Trans> : <Trans>Two-Factor Authentication</Trans>}
</h1>
<div className="text-muted-foreground">
<Trans>Enter the verification code from your authenticator app</Trans>
{backupCode ? (
<Trans>Enter one of your saved backup codes to access your account</Trans>
) : (
<Trans>Enter the verification code from your authenticator app</Trans>
)}
</div>
</div>
@@ -74,8 +91,8 @@ export function VerifyTwoFactorPage() {
<FormControl
render={
<Input
type="number"
maxLength={6}
type={backupCode ? "text" : "number"}
maxLength={backupCode ? 10 : 6}
className="max-w-xs"
name={field.name}
value={field.state.value}
@@ -95,32 +112,50 @@ export function VerifyTwoFactorPage() {
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/login">
<Link to={backupCode ? "/auth/verify-2fa" : "/auth/login"}>
<ArrowLeftIcon />
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
{backupCode ? (
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
) : (
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
)}
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
{backupCode ? (
<Trans comment="Primary action button to submit backup code">Verify</Trans>
) : (
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
)}
</Button>
</div>
</form>
<Button
variant="link"
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
Lost access to your authenticator?
</Trans>
</Link>
}
/>
{!backupCode && (
<Button
variant="link"
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
Lost access to your authenticator?
</Trans>
</Link>
}
/>
)}
</>
);
}
export function VerifyTwoFactorPage() {
return <TwoFactorVerificationPage />;
}
export function VerifyTwoFactorBackupPage() {
return <TwoFactorVerificationPage backupCode />;
}
@@ -1,23 +1,16 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { changeLocale, localeMap } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LanguageCommandPage() {
const { i18n } = useLingui();
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
<CommandItem key={value} onSelect={() => changeLocale(value)}>
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{i18n.t(label)}
</CommandItem>
+2 -9
View File
@@ -1,18 +1,11 @@
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, setLocaleCookie } from "@/libs/locale";
import { changeLocale } from "@/libs/locale";
import { getLocaleOptions } from "./locale-options";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
@@ -21,7 +14,7 @@ export function LocaleCombobox(props: Props) {
showClear={false}
defaultValue={i18n.locale}
options={getLocaleOptions()}
onValueChange={onLocaleChange}
onValueChange={changeLocale}
{...props}
/>
);
+2 -10
View File
@@ -106,10 +106,6 @@ function cloneResume(resume: Resume): Resume {
return { ...resume, data: cloneResumeData(resume.data) };
}
function createResumeUpdateEventIterator(resumeId: string) {
return streamClient.resume.updates.subscribe({ id: resumeId });
}
export function isEditableElementFocused(): boolean {
if (typeof document === "undefined") return false;
const element = document.activeElement as HTMLElement | null;
@@ -521,10 +517,6 @@ export const usePreviewPausedStore = create<PreviewPausedStore>()((set) => ({
setPaused: (paused) => set({ paused }),
}));
function useResetResumeStore() {
return useResumeStore((state) => state.reset);
}
export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
@@ -587,7 +579,7 @@ export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: Res
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), {
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
onEvent: async (event) => {
try {
await onUpdate((event ?? { mutation: "sync" }) as ResumeUpdateEvent);
@@ -664,7 +656,7 @@ export function useBuilderResumeUpdateSubscription() {
export function useResumeCleanup() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const reset = useResetResumeStore();
const reset = useResumeStore((state) => state.reset);
useEffect(() => {
if (!resumeId) return;
@@ -1,8 +1,8 @@
import type { PreviewPageSize } from "./preview.shared.utils";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
return await new Promise<Blob>((resolve, reject) => {
const canvasToBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to create resume thumbnail image."));
@@ -12,7 +12,6 @@ const canvasToBlob = async (canvas: HTMLCanvasElement) => {
resolve(blob);
}, "image/png");
});
};
export const createPdfFirstPageImageUrl = async (file: Blob) => {
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { m } from "motion/react";
type ActionButtonProps = {
children: ReactNode;
};
export function ActionButton({ children }: ActionButtonProps) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
@@ -21,9 +21,7 @@ export function PasskeysSection() {
});
const registerPasskeyMutation = useMutation({
mutationFn: async () => {
return await authClient.passkey.addPasskey();
},
mutationFn: () => authClient.passkey.addPasskey(),
onSuccess: async ({ data, error }) => {
if (error) {
toast.error(
@@ -77,9 +75,7 @@ export function PasskeysSection() {
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
return await authClient.passkey.deletePasskey({ id });
},
mutationFn: (id: string) => authClient.passkey.deletePasskey({ id }),
onSuccess: async ({ error }) => {
if (error) {
toast.error(
@@ -1,41 +1,18 @@
import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
// ponytail: m.div wrapper is identical for both branches — extracted, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
export function PasswordSection() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const hasPassword = hasAccount("credential");
const handleUpdatePassword = useCallback(() => {
if (hasPassword) {
openDialog("auth.change-password", undefined);
} else {
void navigate({ to: "/auth/forgot-password" });
}
}, [hasPassword, navigate, openDialog]);
return (
<m.div
initial={{ y: -20 }}
@@ -50,7 +27,7 @@ export function PasswordSection() {
<ActionButton>
{hasPassword ? (
<Button variant="outline" onClick={handleUpdatePassword}>
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
@@ -2,25 +2,11 @@ import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { ActionButton } from "./action-button";
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
// ponytail: shared hover/tap wrapper — identical in both branches, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
type SocialProviderSectionProps = {
provider: AuthProvider;
name?: string;
@@ -37,15 +23,6 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
const account = getAccountByProviderId(provider);
const isConnected = hasAccount(provider);
const handleLink = useCallback(async () => {
await link(provider);
}, [link, provider]);
const handleUnlink = useCallback(async () => {
if (!account?.accountId) return;
await unlink(provider, account.accountId);
}, [account, unlink, provider]);
return (
<m.div
className="will-change-[transform,opacity]"
@@ -63,14 +40,19 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
<ActionButton>
{isConnected ? (
<Button variant="outline" onClick={handleUnlink}>
<Button
variant="outline"
onClick={() => {
if (account?.accountId) void unlink(provider, account.accountId);
}}
>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
) : (
<Button variant="outline" onClick={handleLink}>
<Button variant="outline" onClick={() => void link(provider)}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
@@ -1,27 +1,13 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useDialogStore } from "@/dialogs/store";
import { authClient } from "@/libs/auth/client";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
// ponytail: shared hover/tap wrapper — identical in both branches, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
export function TwoFactorSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
@@ -30,14 +16,6 @@ export function TwoFactorSection() {
const hasPassword = hasAccount("credential");
const hasTwoFactor = session?.user.twoFactorEnabled ?? false;
const handleTwoFactorAction = useCallback(() => {
if (hasTwoFactor) {
openDialog("auth.two-factor.disable", undefined);
} else {
openDialog("auth.two-factor.enable", undefined);
}
}, [hasTwoFactor, openDialog]);
if (!hasPassword) return null;
return (
@@ -56,7 +34,10 @@ export function TwoFactorSection() {
</h2>
<ActionButton>
<Button variant="outline" onClick={handleTwoFactorAction}>
<Button
variant="outline"
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
>
{hasTwoFactor ? (
<>
<ToggleLeftIcon />
@@ -6,12 +6,13 @@ import { orpc } from "@/libs/orpc/client";
* Replaces the predicate that was duplicated across the import dialog, agent setup, and AI settings.
*/
export function useHasUsableAiProvider() {
const { data: providers, isLoading } = useQuery(orpc.aiProviders.list.queryOptions());
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const usableProviders = (providers ?? []).filter((provider) => provider.enabled && provider.testStatus === "success");
return {
error,
hasUsableProvider: usableProviders.length > 0,
usableProviders,
isLoading,
usableProviders,
};
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function ThemeCombobox(props: Props) {
keywords: [i18n.t(label)],
}));
const onThemeChange = async (value: string | null) => {
const onThemeChange = (value: string | null) => {
if (!value || !isTheme(value)) return;
setTheme(value);
void router.invalidate();
+2 -9
View File
@@ -22,20 +22,13 @@ import {
import { useTheme } from "@/features/theme/provider";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { changeLocale, localeMap } from "@/libs/locale";
import { isTheme } from "@/libs/theme";
type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
@@ -88,7 +81,7 @@ export function UserDropdownMenu({ children }: Props) {
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={changeLocale}>
{Object.entries(localeMap).map(([value, label]) => (
<DropdownMenuRadioItem key={value} value={value}>
{i18n.t(label)}
+5 -5
View File
@@ -24,7 +24,7 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Are you sure?");
});
expect(promise).toBeInstanceOf(Promise);
@@ -34,7 +34,7 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Heading");
});
@@ -44,7 +44,7 @@ describe("useConfirm", () => {
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
const cancel = buttons.find((b) => /cancel/i.test(b.textContent ?? ""));
await act(async () => {
await act(() => {
(cancelBtn as HTMLButtonElement | null)?.click() ?? cancel?.click();
});
@@ -55,14 +55,14 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Heading", { confirmText: "Yes" });
});
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
const yes = buttons.find((b) => /yes/i.test(b.textContent ?? ""));
await act(async () => {
await act(() => {
yes?.click();
});
+1 -1
View File
@@ -43,7 +43,7 @@ export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps)
cancelText: undefined,
});
const confirm = React.useCallback(async (title: string, options?: ConfirmOptions): Promise<boolean> => {
const confirm = React.useCallback((title: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setState({
open: true,
+1 -1
View File
@@ -48,7 +48,7 @@ export function useFormBlocker<TStore extends BlockableFormStore>(
return isDirty && !isSubmitting;
}, [isDirty, isSubmitting]);
const confirmClose = useCallback(async () => {
const confirmClose = useCallback(() => {
if (!shouldBlock()) return true;
return confirm(t`Are you sure you want to close this dialog?`, {
+6 -6
View File
@@ -34,11 +34,11 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Name?");
});
await act(async () => {
await act(() => {
clickButton(/cancel/i);
});
@@ -49,11 +49,11 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Name?", { defaultValue: "Initial" });
});
await act(async () => {
await act(() => {
clickButton(/confirm/i);
});
@@ -64,7 +64,7 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Heading", { defaultValue: "preset" });
});
@@ -72,7 +72,7 @@ describe("usePrompt", () => {
const input = document.body.querySelector("input") as HTMLInputElement | null;
expect(input?.value).toBe("preset");
await act(async () => {
await act(() => {
clickButton(/confirm/i);
});
+1 -1
View File
@@ -67,7 +67,7 @@ export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
return () => window.clearTimeout(timeoutId);
}, [state.open]);
const prompt = React.useCallback(async (title: string, options?: PromptOptions): Promise<string | null> => {
const prompt = React.useCallback((title: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
setState({
open: true,
+21 -25
View File
@@ -13,28 +13,24 @@ import {
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
const getAuthClient = () => {
return createAuthClient({
plugins: [
dashClient(),
adminClient(),
apiKeyClient(),
passkeyClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
// Redirect to 2FA verification page
if (typeof window !== "undefined") {
window.location.href = "/auth/verify-2fa";
}
},
}),
genericOAuthClient(),
oauthProviderClient(),
oauthProviderResourceClient(),
inferAdditionalFields<typeof auth>(),
],
});
};
export const authClient = getAuthClient();
export const authClient = createAuthClient({
plugins: [
dashClient(),
adminClient(),
apiKeyClient(),
passkeyClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
// Redirect to 2FA verification page
if (typeof window !== "undefined") {
window.location.href = "/auth/verify-2fa";
}
},
}),
genericOAuthClient(),
oauthProviderClient(),
oauthProviderResourceClient(),
inferAdditionalFields<typeof auth>(),
],
});
+39 -2
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { isLocale, resolveLocale } from "./locale";
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import Cookies from "js-cookie";
import { changeLocale, formatRelativeTime, isLocale, resolveLocale } from "./locale";
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
Cookies.remove("locale");
});
describe("isLocale", () => {
it("returns true for known locale en-US", () => {
@@ -44,3 +53,31 @@ describe("resolveLocale", () => {
expect(resolveLocale("")).toBe("en-US");
});
});
describe("formatRelativeTime", () => {
it("selects the largest matching unit", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-02T12:00:00Z"));
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("2026-01-02T10:00:00Z", formatter)).toBe("2 hours ago");
expect(formatRelativeTime("2026-01-02T11:59:45Z", formatter)).toBe("now");
});
it("uses the requested fallback for an invalid date", () => {
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("invalid", formatter, "")).toBe("");
});
});
describe("changeLocale", () => {
it("persists a valid locale and reloads", () => {
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => undefined);
changeLocale("de-DE");
expect(Cookies.get("locale")).toBe("de-DE");
expect(reload).toHaveBeenCalledOnce();
});
});
+26 -4
View File
@@ -10,6 +10,14 @@ export { isRTL };
const storageKey = "locale";
const defaultLocale: Locale = "en-US";
const messageLoaders = import.meta.glob<{ messages: Messages }>("../../locales/*.po");
const relativeTimeDivisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
export const localeMap = {
"af-ZA": msg`Afrikaans`,
@@ -77,16 +85,24 @@ export const resolveLocale = (locale: string): Locale => {
return isLocale(locale) ? locale : defaultLocale;
};
export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat, invalidFallback?: string) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
if (Number.isNaN(diffMs)) return invalidFallback ?? formatter.format(0, "second");
const division = relativeTimeDivisions.find((candidate) => Math.abs(diffMs) >= candidate.amount);
return division
? formatter.format(Math.round(diffMs / division.amount), division.unit)
: formatter.format(0, "second");
}
export const getLocale = () => {
const locale = Cookies.get(storageKey);
if (!locale || !isLocale(locale)) return defaultLocale;
return locale;
};
export const setLocaleCookie = (locale: Locale) => {
Cookies.set(storageKey, locale);
};
const loadMessages = async (locale: Locale) => {
const load = messageLoaders[`../../locales/${locale}.po`];
@@ -113,3 +129,9 @@ export const loadLocale = async (locale: string) => {
const { locale: resolvedLocale, messages } = await getLocaleMessages(locale);
i18n.loadAndActivate({ locale: resolvedLocale, messages });
};
export const changeLocale = (value: string | null) => {
if (!value || !isLocale(value)) return;
Cookies.set(storageKey, value);
window.location.reload();
};
+8 -16
View File
@@ -10,8 +10,8 @@ const getRpcUrl = () => {
return `${window.location.origin}/api/rpc`;
};
const createRpcClient = (): RouterClient<typeof router> => {
const link = new RPCLink({
export const client: RouterClient<typeof router> = createORPCClient(
new RPCLink({
url: getRpcUrl(),
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
plugins: [
@@ -26,15 +26,11 @@ const createRpcClient = (): RouterClient<typeof router> => {
console.warn("[oRPC client]", error);
}),
],
});
}),
);
return createORPCClient(link);
};
export const client = createRpcClient();
const createStreamClient = (): RouterClient<typeof router> => {
const link = new RPCLink({
export const streamClient: RouterClient<typeof router> = createORPCClient(
new RPCLink({
url: getRpcUrl(),
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
interceptors: [
@@ -43,12 +39,8 @@ const createStreamClient = (): RouterClient<typeof router> => {
console.warn("[oRPC stream client]", error);
}),
],
});
return createORPCClient(link);
};
export const streamClient = createStreamClient();
}),
);
export const orpc = createTanstackQueryUtils(client);
@@ -6,7 +6,7 @@ import { createSectionTitleResolver } from "./section-title";
const resolverCache = new Map<string, Promise<SectionTitleResolver>>();
export const createSectionTitleResolverForLocale = async (localeParam: string) => {
export const createSectionTitleResolverForLocale = (localeParam: string) => {
const requestedLocale = resolveLocale(localeParam);
const cachedResolver = resolverCache.get(requestedLocale);
+84
View File
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import type { Website } from "@reactive-resume/schema/resume/data";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useAppForm } from "./tanstack-form";
vi.mock("@/components/input/url-input", () => ({
URLInput: ({
value,
onChange,
hideLabelButton,
}: {
value: Website;
onChange: (value: Website) => void;
hideLabelButton?: boolean;
}) => (
<input
aria-label="Website value"
data-hide-label-button={hideLabelButton}
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value })}
/>
),
}));
vi.mock("@/components/input/rich-input", () => ({
RichInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea aria-label="Description value" value={value} onChange={(event) => onChange(event.target.value)} />
),
}));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
function TestForm() {
const form = useAppForm({
defaultValues: {
description: "Initial description",
website: { url: "https://example.com", label: "Example", inlineLink: false },
},
});
return (
<I18nProvider i18n={i18n}>
<form.AppField
name="website"
validators={{ onChange: ({ value }) => (value.url ? undefined : "Website is required") }}
>
{(field) => <field.WebsiteField label="Website" hideLabelButton formItemClassName="website-field" />}
</form.AppField>
<form.AppField
name="description"
validators={{ onChange: ({ value }) => (value ? undefined : "Description is required") }}
>
{(field) => <field.RichTextField label="Description" formItemClassName="description-field" />}
</form.AppField>
</I18nProvider>
);
}
describe("registered resume fields", () => {
it("preserves labels, layout classes, attributes, values, and errors", async () => {
const user = userEvent.setup();
const { container } = render(<TestForm />);
expect(screen.getByText("Website")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(container.querySelector(".website-field")).toBeInTheDocument();
expect(container.querySelector(".description-field")).toBeInTheDocument();
expect(screen.getByLabelText("Website value")).toHaveAttribute("data-hide-label-button", "true");
await user.clear(screen.getByLabelText("Website value"));
await user.clear(screen.getByLabelText("Description value"));
expect(screen.getByText("Website is required")).toBeInTheDocument();
expect(screen.getByText("Description is required")).toBeInTheDocument();
});
});
+45 -1
View File
@@ -1,8 +1,11 @@
import type { Website } from "@reactive-resume/schema/resume/data";
import type * as React from "react";
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { InputGroupInput } from "@reactive-resume/ui/components/input-group";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
type FieldFrameProps = {
label?: React.ReactNode;
@@ -25,6 +28,17 @@ type NumberFieldProps = FieldFrameProps &
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "type" | "value"
>;
type WebsiteFieldProps = {
label: React.ReactNode;
formItemClassName?: string;
hideLabelButton: boolean;
};
type RichTextFieldProps = {
label: React.ReactNode;
formItemClassName?: string;
};
const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
function TextField({ label, description, formItemClassName, ...props }: TextFieldProps) {
@@ -103,8 +117,38 @@ function NumberField({ label, description, formItemClassName, ...props }: Number
);
}
function WebsiteField({ label, formItemClassName, hideLabelButton }: WebsiteFieldProps) {
const field = useFieldContext<Website>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
<FormLabel>{label}</FormLabel>
<URLInput
value={field.state.value}
onChange={(value) => field.handleChange(value)}
hideLabelButton={hideLabelButton}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
);
}
function RichTextField({ label, formItemClassName }: RichTextFieldProps) {
const field = useFieldContext<string>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
<FormLabel>{label}</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(value) => field.handleChange(value)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
);
}
export const { useAppForm, withForm } = createFormHook({
fieldComponents: { InputGroupTextField, NumberField, TextField },
fieldComponents: { InputGroupTextField, NumberField, RichTextField, TextField, WebsiteField },
fieldContext,
formComponents: {},
formContext,
+2 -3
View File
@@ -16,7 +16,7 @@ import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { Toaster } from "@reactive-resume/ui/components/sonner";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
@@ -45,6 +45,7 @@ const tagline = "A free and open-source resume builder";
const title = `${appName}${tagline}`;
const description =
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
const iconContextValue: IconProps = { size: 16, weight: "regular" };
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@@ -107,8 +108,6 @@ function RootComponent() {
// Suppress the app-wide donation toast inside the builder so it doesn't cover the right-sidebar controls.
const isBuilder = useRouterState({ select: (s) => s.location.pathname.startsWith("/builder") });
const iconContextValue = useMemo<IconProps>(() => ({ size: 16, weight: "regular" }), []);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
@@ -1,7 +1,6 @@
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
import { Trans } from "@lingui/react/macro";
import { m } from "motion/react";
import { useMemo } from "react";
import { templates } from "@/dialogs/resume/template/data";
type TemplateItemProps = {
@@ -75,21 +74,12 @@ const createMarqueeItems = (entries: Array<[string, TemplateMetadata]>, rowId: s
{ id: `${rowId}-${template}-repeat`, metadata },
]);
const templateEntries = Object.entries(templates);
const halfway = Math.ceil(templateEntries.length / 2);
const row1 = createMarqueeItems(templateEntries.slice(0, halfway), "row1");
const row2 = createMarqueeItems(templateEntries.slice(halfway), "row2");
export function Templates() {
// Split templates into two rows and duplicate for seamless infinite scroll
const { row1, row2 } = useMemo(() => {
const entries = Object.entries(templates);
const half = Math.ceil(entries.length / 2);
const firstHalf = entries.slice(0, half);
const secondHalf = entries.slice(half);
// Duplicate each row for seamless scrolling
return {
row1: createMarqueeItems(firstHalf, "row1"),
row2: createMarqueeItems(secondHalf, "row2"),
};
}, []);
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<m.div
@@ -1,7 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { QuotesIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useMemo } from "react";
const email = "hello@amruthpillai.com";
@@ -70,6 +69,11 @@ type TestimonialColumnData = {
testimonials: string[];
};
const testimonialColumns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
testimonialColumns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
type MarqueeMasonryProps = {
columns: TestimonialColumnData[];
direction: "left" | "right";
@@ -97,16 +101,6 @@ function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryPro
}
export function Testimonials() {
const columns = useMemo(() => {
const columns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
columns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
return columns;
}, []);
return (
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
<m.div
@@ -145,7 +139,7 @@ export function Testimonials() {
{/* Right fade */}
<div className="pointer-events-none absolute inset-e-0 top-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
<MarqueeMasonry columns={columns} direction="left" duration={60} />
<MarqueeMasonry columns={testimonialColumns} direction="left" duration={60} />
</div>
</section>
);
@@ -4,7 +4,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -12,6 +12,7 @@ import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -34,20 +35,12 @@ function isAgentConfigError(error: unknown) {
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const isClient = useIsClient();
const navigate = useNavigate();
const {
data: providers,
isLoading: isLoadingProviders,
error: providersError,
} = useQuery(orpc.aiProviders.list.queryOptions());
const { usableProviders, isLoading: isLoadingProviders, error: providersError } = useHasUsableAiProvider();
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
);
const { mutate: createThread, isPending } = useMutation(orpc.agent.threads.create.mutationOptions());
const usableProviders = useMemo(
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderIdOverride, setAiProviderIdOverride] = useState<string | null | undefined>(undefined);
const [sourceResumeIdOverride, setSourceResumeIdOverride] = useState<string | null | undefined>(undefined);
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
@@ -25,6 +25,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
@@ -41,28 +42,6 @@ type AgentThreadSidebarProps = {
className?: string;
};
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 60_000) return formatter.format(0, "second");
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
function ThreadActions({ thread, activeThreadId }: ThreadActionsProps) {
const navigate = useNavigate();
const confirm = useConfirm();
@@ -164,7 +143,7 @@ function ThreadRow({ thread, activeThreadId }: ThreadRowProps) {
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter, "")}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
+1 -1
View File
@@ -3,7 +3,7 @@ import { createNoindexFollowMeta } from "@/libs/seo";
export const Route = createFileRoute("/agent")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { ForgotPasswordPage } from "@/features/auth/pages/forgot-password";
export const Route = createFileRoute("/auth/forgot-password")({
component: ForgotPasswordPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/auth/")({
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
throw redirect({ to: "/auth/login", replace: true });
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { LoginPage } from "@/features/auth/pages/login";
export const Route = createFileRoute("/auth/login")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
return { session: null };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { RegisterPage } from "@/features/auth/pages/register";
export const Route = createFileRoute("/auth/register")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", replace: true });
return { session: null };
+1 -1
View File
@@ -7,7 +7,7 @@ const searchSchema = z.object({ token: z.string().min(1) });
export const Route = createFileRoute("/auth/reset-password")({
component: RouteComponent,
validateSearch: searchSchema,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
onError: (error) => {
@@ -1,9 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa-backup";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa-backup")({
component: VerifyTwoFactorBackupPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { VerifyTwoFactorPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa")({
component: VerifyTwoFactorPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
@@ -15,7 +15,6 @@ import {
import { useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
@@ -72,15 +71,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
redo();
});
const publicUrl = useMemo(() => {
if (!session?.user.username || !resumeSlug) return "";
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
}, [session?.user.username, resumeSlug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const publicUrl =
session?.user.username && resumeSlug ? `${window.location.origin}/${session.user.username}/${resumeSlug}` : "";
return (
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
@@ -111,7 +103,14 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon
icon={LinkSimpleIcon}
title={t`Copy URL`}
onClick={async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}}
/>
</m.div>
</div>
);
@@ -19,29 +19,9 @@ import {
import { useResumeStore } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
// No division matches only when the gap is under a minute (the smallest division), so fall back to seconds.
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return formatter.format(0, "second");
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
type BuilderVersionHistoryProps = {
resumeId: string;
};
@@ -326,7 +326,6 @@ describe("CustomStylesSectionBuilder", () => {
fireEvent.blur(fontSizeInput);
expect(fontSizeInput).toHaveValue(12);
expect(updateResumeData).toHaveBeenCalledTimes(2);
});
it("commits normalized legacy values when the input loses focus", () => {
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -93,13 +92,8 @@ export function ResumeAnalysisSectionBuilder() {
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
if (score == null) return "bg-muted";
if (score >= 80) return "bg-emerald-600";
if (score >= 60) return "bg-amber-600";
return "bg-rose-600";
}, [score]);
const scoreTone =
score == null ? "bg-muted" : score >= 80 ? "bg-emerald-600" : score >= 60 ? "bg-amber-600" : "bg-rose-600";
const onAnalyze = () => {
if (!resume) return;
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
@@ -29,10 +29,7 @@ export function SharingSectionBuilder() {
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const publicUrl = useMemo(() => {
if (!session) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session, resume]);
const publicUrl = session ? `${window.location.origin}/${session.user.username}/${resume.slug}` : "";
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
@@ -1,6 +1,6 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
@@ -135,17 +135,14 @@ export function useBuilderSidebar(): UseBuilderSidebarReturn {
[expandSize],
);
// ponytail: memoized but callers destructure; selector removed (state rebuilt every render, zero benefit)
return useMemo(() => {
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}, [maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior, isCollapsed, toggleSidebar]);
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}
export const setBuilderLayout = (data: BuilderLayout) => {
@@ -12,7 +12,7 @@ import { getBuilderLayout } from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -19,10 +16,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = {
};
export function ResumeContextMenu({ resume, children }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<ContextMenu>
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,10 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children">
};
export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<DropdownMenu>
@@ -0,0 +1,54 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type Resume = RouterOutput["resume"]["list"][number];
export function useResumeMenuActions(resume: Resume) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmed = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmed) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{ onError: (error) => toast.error(getResumeErrorMessage(error)) },
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmed) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => toast.success(t`Your resume has been deleted successfully.`, { id: toastId }),
onError: (error) => toast.error(getResumeErrorMessage(error), { id: toastId }),
},
);
};
return {
handleDelete,
handleDuplicate: () => openDialog("resume.duplicate", resume),
handleToggleLock,
handleUpdate: () => openDialog("resume.update", resume),
};
}
+2 -2
View File
@@ -7,11 +7,11 @@ import { DashboardSidebar } from "./-components/sidebar";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async () => {
loader: () => {
const sidebarState = getDashboardSidebarState();
return { sidebarState };
},