mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 22:32:17 +10:00
refactor: ponytail audit
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?`, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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,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 });
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user