refactor: ponytail audit

This commit is contained in:
Amruth Pillai
2026-07-27 20:26:16 +02:00
parent bb1fb3a7d6
commit 9110e86997
157 changed files with 1082 additions and 2177 deletions
+1 -3
View File
@@ -37,9 +37,7 @@ async function checkDatabase() {
return { status: "healthy" };
}
async function checkStorage() {
return getStorageService().healthcheck();
}
const checkStorage = () => getStorageService().healthcheck();
export async function handleHealth() {
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
+1 -1
View File
@@ -7,7 +7,7 @@ export async function handleMcp(request: Request) {
try {
await authenticateRequest(request);
const server = await createMcpServer(request);
const server = createMcpServer(request);
const transport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
+1 -1
View File
@@ -22,7 +22,7 @@ function createRequestClient(request: Request): RouterClient<typeof router> {
});
}
export async function createMcpServer(request: Request) {
export function createMcpServer(request: Request) {
const server = new McpServer(
{
name: "reactive-resume",
+3 -11
View File
@@ -4,8 +4,8 @@ import { env } from "@reactive-resume/env/server";
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
import { appVersion } from "../app-version";
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
export function handleWellKnownFallback() {
return new Response("OK", { status: 200 });
@@ -20,15 +20,7 @@ export function handleMcpServerCard() {
});
}
export function handleOAuthAuthorizationServer(request: Request) {
return oauthAuthorizationServerHandler(request);
}
export function handleOpenIdConfiguration(request: Request) {
return openIdConfigurationHandler(request);
}
export async function handleOAuthProtectedResource() {
export function handleOAuthProtectedResource() {
const metadata = {
resource: env.APP_URL,
bearer_methods_supported: ["header"],
+2 -2
View File
@@ -176,7 +176,7 @@ export function ChipInput({
});
if (nextValues.length === 0) return;
const newChips = Array.from(new Set([...chips, ...nextValues]));
const newChips = [...new Set([...chips, ...nextValues])];
setChips(newChips);
},
[chips, setChips],
@@ -269,7 +269,7 @@ export function ChipInput({
const oldIndex = chips.indexOf(active.id as string);
const newIndex = chips.indexOf(over.id as string);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = Array.from(chips);
const newOrder = [...chips];
const [removed] = newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, removed);
handleReorder(newOrder);
+8 -22
View File
@@ -3,7 +3,7 @@ import type { CellComponentProps } from "react-window";
import { t } from "@lingui/core/macro";
import { ProhibitIcon } from "@phosphor-icons/react";
import Fuse from "fuse.js";
import { memo, useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { Grid } from "react-window";
import { icons } from "@reactive-resume/schema/icons";
import { Button } from "@reactive-resume/ui/components/button";
@@ -14,6 +14,7 @@ import { cn } from "@reactive-resume/utils/style";
const columnCount = 8;
const columnWidth = 36;
const rowHeight = 36;
const iconSearch = new Fuse(icons, { threshold: 0.35 });
type IconSearchInputProps = {
value: string;
@@ -21,7 +22,7 @@ type IconSearchInputProps = {
className?: string;
};
function _IconSearchInput(props: IconSearchInputProps) {
function IconSearchInput(props: IconSearchInputProps) {
return (
<Input
spellCheck={false}
@@ -41,10 +42,6 @@ function _IconSearchInput(props: IconSearchInputProps) {
);
}
const IconSearchInput = memo(_IconSearchInput);
IconSearchInput.displayName = "IconSearchInput";
type IconCellComponentProps = CellComponentProps & {
icons: IconName[];
onChange: (icon: IconName) => void;
@@ -70,18 +67,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
);
}
function useIconSearch() {
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
const search = useCallback(
(query: string): IconName[] => {
if (!query.trim()) return Array.from(icons);
return fuse.search(query).map((result) => result.item);
},
[fuse],
);
return search;
function searchIcons(query: string): IconName[] {
if (!query.trim()) return [...icons];
return iconSearch.search(query).map((result) => result.item);
}
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
@@ -91,12 +79,10 @@ type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onCh
};
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
const searchIcons = useIconSearch();
const [search, setSearch] = useState("");
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
const searchedIcons = useMemo(() => searchIcons(search), [search]);
const rowCount = Math.ceil(searchedIcons.length / columnCount);
return (
<Popover {...popoverProps}>
@@ -6,7 +6,7 @@ export function getNextWeights(fontFamily: string): Weight[] | null {
const fontData = getFont(fontFamily);
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
const uniqueWeights = [...new Set(fontData.weights)] as Weight[];
// Try to pick 400 and 600 if available
const weights: Weight[] = [];
+1 -1
View File
@@ -284,7 +284,7 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">)
const toastId = toast.loading(t`Duplicating your resume...`);
duplicateResume(value, {
onSuccess: async (id) => {
onSuccess: (id) => {
toast.success(t`Your resume has been duplicated successfully.`, { id: toastId });
closeDialog();
+8 -33
View File
@@ -7,8 +7,6 @@ import { awardItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -38,7 +36,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "awards", value, data?.customSectionId);
});
@@ -70,7 +68,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "awards", value, data?.customSectionId);
});
@@ -127,21 +125,9 @@ const AwardForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -163,20 +149,9 @@ const AwardForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "certifications", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "certifications", value, data?.customSectionId);
});
@@ -107,21 +105,9 @@ const CertificationForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +129,9 @@ const CertificationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -31,7 +31,7 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -67,7 +67,7 @@ export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -88,7 +88,7 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
items: data?.items ?? [],
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
draft.customSections.push(value);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
@@ -146,7 +146,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
icon: data.icon ?? "",
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === value.id);
if (index === -1) return;
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -40,7 +38,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "education", value, data?.customSectionId);
});
@@ -72,7 +70,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "education", value, data?.customSectionId);
});
@@ -116,24 +114,15 @@ const EducationForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -155,20 +144,9 @@ const EducationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -12,7 +12,6 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { generateId } from "@reactive-resume/utils/string";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -44,7 +43,7 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "experience", value, data?.customSectionId);
});
@@ -76,7 +75,7 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "experience", value, data?.customSectionId);
});
@@ -122,24 +121,15 @@ const ExperienceForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -219,20 +209,9 @@ const ExperienceForm = withForm({
{/* Single Role Description — only show when no roles are defined */}
{!hasRoles && (
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
)}
</>
);
@@ -39,7 +39,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "interests", value, data?.customSectionId);
});
@@ -71,7 +71,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "interests", value, data?.customSectionId);
});
@@ -34,7 +34,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "languages", value, data?.customSectionId);
});
@@ -66,7 +66,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "languages", value, data?.customSectionId);
});
@@ -17,7 +17,6 @@ import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -47,7 +46,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "profiles", value, data?.customSectionId);
});
@@ -79,7 +78,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "profiles", value, data?.customSectionId);
});
@@ -211,24 +210,15 @@ const ProfileForm = withForm({
)}
</form.Field>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { projectItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -36,7 +34,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "projects", value, data?.customSectionId);
});
@@ -68,7 +66,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "projects", value, data?.customSectionId);
});
@@ -104,24 +102,15 @@ const ProjectForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
<form.AppField name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
<field.WebsiteField
label={<Trans>Website</Trans>}
formItemClassName="sm:col-span-full"
hideLabelButton={inlineLink}
/>
)}
</form.Field>
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +132,9 @@ const ProjectForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "publications", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "publications", value, data?.customSectionId);
});
@@ -109,21 +107,9 @@ const PublicationForm = withForm({
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -145,20 +131,9 @@ const PublicationForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "references", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "references", value, data?.customSectionId);
});
@@ -107,21 +105,9 @@ const ReferenceForm = withForm({
<form.AppField name="phone">{(field) => <field.TextField label={<Trans>Phone</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -143,20 +129,9 @@ const ReferenceForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -43,7 +43,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "skills", value, data?.customSectionId);
});
@@ -75,7 +75,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "skills", value, data?.customSectionId);
});
@@ -30,7 +30,7 @@ export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.s
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -66,7 +66,7 @@ export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.s
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeStore((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
@@ -4,10 +4,8 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { FormControl, FormItem, FormLabel } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -37,7 +35,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "volunteer", value, data?.customSectionId);
});
@@ -69,7 +67,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
onSubmit: ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "volunteer", value, data?.customSectionId);
});
@@ -109,21 +107,9 @@ const VolunteerForm = withForm({
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="website">
{(field) => <field.WebsiteField label={<Trans>Website</Trans>} hideLabelButton={inlineLink} />}
</form.AppField>
<form.Field name="website.inlineLink">
{(field) => (
@@ -145,20 +131,9 @@ const VolunteerForm = withForm({
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="description">
{(field) => <field.RichTextField label={<Trans>Description</Trans>} formItemClassName="sm:col-span-full" />}
</form.AppField>
</>
);
},
@@ -40,7 +40,7 @@ const stageOf = (status: ApplicationStatus) => STAGES.find((s) => s.value === st
const dateInputValue = (value: Date | string) => {
const date = new Date(value);
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
};
const formatDate = (value: Date | string) =>
@@ -27,10 +27,7 @@ import { FileAttachmentField } from "./file-attachment-field";
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
const todayInputValue = () => {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`;
};
const todayInputValue = () => new Date().toISOString().slice(0, 10);
const emptyForm = () => ({
company: "",
@@ -4,7 +4,6 @@ import { ORPCError } from "@orpc/client";
import { EyeIcon, EyeSlashIcon, LockOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { useToggle } from "usehooks-ts";
import z from "zod";
@@ -29,16 +28,13 @@ export function ResumePasswordPage({ redirectPath }: Props) {
const { mutate: verifyPassword } = useMutation(orpc.resume.verifyPassword.mutationOptions());
const [username, slug] = useMemo(() => {
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
if (!username || !slug) throw navigate({ to: "/" });
return [username, slug];
}, [redirectPath, navigate]);
const [username, slug] = redirectPath.split("/").slice(1) as [string, string];
if (!username || !slug) throw navigate({ to: "/" });
const form = useAppForm({
defaultValues: { password: "" },
validators: { onSubmit: formSchema },
onSubmit: async ({ value, formApi }) => {
onSubmit: ({ value, formApi }) => {
const toastId = toast.loading(t`Verifying password...`);
verifyPassword(
@@ -1,111 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
code: z.string().trim(),
});
export function VerifyTwoFactorBackupPage() {
const router = useRouter();
const navigate = useNavigate();
const form = useAppForm({
defaultValues: { code: "" },
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
const toastId = toast.loading(t`Verifying backup code...`);
const formattedCode = `${value.code.slice(0, 5)}-${value.code.slice(5)}`;
const { error } = await authClient.twoFactor.verifyBackupCode({ code: formattedCode });
if (error) {
toast.error(
error.message ||
t({
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
message: "Failed to verify your backup code. Please try again.",
}),
{ id: toastId },
);
return;
}
toast.dismiss(toastId);
await router.invalidate();
void navigate({ to: "/dashboard", replace: true });
},
});
return (
<>
<div className="space-y-1 text-center">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Verify with a Backup Code</Trans>
</h1>
<div className="text-muted-foreground">
<Trans>Enter one of your saved backup codes to access your account</Trans>
</div>
</div>
<form
className="grid gap-6"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="code">
{(field) => (
<FormItem
className="justify-self-center"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<Input
maxLength={10}
className="max-w-xs"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<div className="flex gap-x-2">
<Button
variant="outline"
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/verify-2fa">
<ArrowLeftIcon />
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
<Trans comment="Primary action button to submit backup code">Verify</Trans>
</Button>
</div>
</form>
</>
);
}
+66 -31
View File
@@ -10,31 +10,44 @@ import { Input } from "@reactive-resume/ui/components/input";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
const totpSchema = z.object({
code: z.string().length(6, "Code must be 6 digits"),
});
export function VerifyTwoFactorPage() {
const backupCodeSchema = z.object({
code: z.string().trim(),
});
type TwoFactorVerificationPageProps = {
backupCode?: boolean;
};
function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerificationPageProps) {
const router = useRouter();
const navigate = useNavigate();
const form = useAppForm({
defaultValues: { code: "" },
validators: { onSubmit: formSchema },
validators: { onSubmit: backupCode ? backupCodeSchema : totpSchema },
onSubmit: async ({ value }) => {
const toastId = toast.loading(t`Verifying code...`);
const { error } = await authClient.twoFactor.verifyTotp({
code: value.code,
});
const toastId = toast.loading(backupCode ? t`Verifying backup code...` : t`Verifying code...`);
const code = backupCode ? `${value.code.slice(0, 5)}-${value.code.slice(5)}` : value.code;
const { error } = backupCode
? await authClient.twoFactor.verifyBackupCode({ code })
: await authClient.twoFactor.verifyTotp({ code });
if (error) {
toast.error(
error.message ||
t({
comment: "Fallback toast when verifying a two-factor authentication code fails",
message: "Failed to verify your code. Please try again.",
}),
(backupCode
? t({
comment: "Fallback toast when verifying a backup two-factor authentication code fails",
message: "Failed to verify your backup code. Please try again.",
})
: t({
comment: "Fallback toast when verifying a two-factor authentication code fails",
message: "Failed to verify your code. Please try again.",
})),
{ id: toastId },
);
return;
@@ -50,10 +63,14 @@ export function VerifyTwoFactorPage() {
<>
<div className="space-y-1 text-center">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Two-Factor Authentication</Trans>
{backupCode ? <Trans>Verify with a Backup Code</Trans> : <Trans>Two-Factor Authentication</Trans>}
</h1>
<div className="text-muted-foreground">
<Trans>Enter the verification code from your authenticator app</Trans>
{backupCode ? (
<Trans>Enter one of your saved backup codes to access your account</Trans>
) : (
<Trans>Enter the verification code from your authenticator app</Trans>
)}
</div>
</div>
@@ -74,8 +91,8 @@ export function VerifyTwoFactorPage() {
<FormControl
render={
<Input
type="number"
maxLength={6}
type={backupCode ? "text" : "number"}
maxLength={backupCode ? 10 : 6}
className="max-w-xs"
name={field.name}
value={field.state.value}
@@ -95,32 +112,50 @@ export function VerifyTwoFactorPage() {
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/login">
<Link to={backupCode ? "/auth/verify-2fa" : "/auth/login"}>
<ArrowLeftIcon />
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
{backupCode ? (
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
) : (
<Trans comment="Secondary navigation button on 2FA verification screen">Back to Login</Trans>
)}
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
{backupCode ? (
<Trans comment="Primary action button to submit backup code">Verify</Trans>
) : (
<Trans comment="Primary action button to submit 2FA code">Verify</Trans>
)}
</Button>
</div>
</form>
<Button
variant="link"
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
Lost access to your authenticator?
</Trans>
</Link>
}
/>
{!backupCode && (
<Button
variant="link"
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
Lost access to your authenticator?
</Trans>
</Link>
}
/>
)}
</>
);
}
export function VerifyTwoFactorPage() {
return <TwoFactorVerificationPage />;
}
export function VerifyTwoFactorBackupPage() {
return <TwoFactorVerificationPage backupCode />;
}
@@ -1,23 +1,16 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { changeLocale, localeMap } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LanguageCommandPage() {
const { i18n } = useLingui();
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
<CommandItem key={value} onSelect={() => changeLocale(value)}>
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{i18n.t(label)}
</CommandItem>
+2 -9
View File
@@ -1,18 +1,11 @@
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, setLocaleCookie } from "@/libs/locale";
import { changeLocale } from "@/libs/locale";
import { getLocaleOptions } from "./locale-options";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
@@ -21,7 +14,7 @@ export function LocaleCombobox(props: Props) {
showClear={false}
defaultValue={i18n.locale}
options={getLocaleOptions()}
onValueChange={onLocaleChange}
onValueChange={changeLocale}
{...props}
/>
);
+2 -10
View File
@@ -106,10 +106,6 @@ function cloneResume(resume: Resume): Resume {
return { ...resume, data: cloneResumeData(resume.data) };
}
function createResumeUpdateEventIterator(resumeId: string) {
return streamClient.resume.updates.subscribe({ id: resumeId });
}
export function isEditableElementFocused(): boolean {
if (typeof document === "undefined") return false;
const element = document.activeElement as HTMLElement | null;
@@ -521,10 +517,6 @@ export const usePreviewPausedStore = create<PreviewPausedStore>()((set) => ({
setPaused: (paused) => set({ paused }),
}));
function useResetResumeStore() {
return useResumeStore((state) => state.reset);
}
export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
@@ -587,7 +579,7 @@ export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: Res
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), {
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
onEvent: async (event) => {
try {
await onUpdate((event ?? { mutation: "sync" }) as ResumeUpdateEvent);
@@ -664,7 +656,7 @@ export function useBuilderResumeUpdateSubscription() {
export function useResumeCleanup() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const reset = useResetResumeStore();
const reset = useResumeStore((state) => state.reset);
useEffect(() => {
if (!resumeId) return;
@@ -1,8 +1,8 @@
import type { PreviewPageSize } from "./preview.shared.utils";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
return await new Promise<Blob>((resolve, reject) => {
const canvasToBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to create resume thumbnail image."));
@@ -12,7 +12,6 @@ const canvasToBlob = async (canvas: HTMLCanvasElement) => {
resolve(blob);
}, "image/png");
});
};
export const createPdfFirstPageImageUrl = async (file: Blob) => {
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { m } from "motion/react";
type ActionButtonProps = {
children: ReactNode;
};
export function ActionButton({ children }: ActionButtonProps) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
@@ -21,9 +21,7 @@ export function PasskeysSection() {
});
const registerPasskeyMutation = useMutation({
mutationFn: async () => {
return await authClient.passkey.addPasskey();
},
mutationFn: () => authClient.passkey.addPasskey(),
onSuccess: async ({ data, error }) => {
if (error) {
toast.error(
@@ -77,9 +75,7 @@ export function PasskeysSection() {
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
return await authClient.passkey.deletePasskey({ id });
},
mutationFn: (id: string) => authClient.passkey.deletePasskey({ id }),
onSuccess: async ({ error }) => {
if (error) {
toast.error(
@@ -1,41 +1,18 @@
import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
// ponytail: m.div wrapper is identical for both branches — extracted, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
export function PasswordSection() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
const hasPassword = hasAccount("credential");
const handleUpdatePassword = useCallback(() => {
if (hasPassword) {
openDialog("auth.change-password", undefined);
} else {
void navigate({ to: "/auth/forgot-password" });
}
}, [hasPassword, navigate, openDialog]);
return (
<m.div
initial={{ y: -20 }}
@@ -50,7 +27,7 @@ export function PasswordSection() {
<ActionButton>
{hasPassword ? (
<Button variant="outline" onClick={handleUpdatePassword}>
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
@@ -2,25 +2,11 @@ import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { ActionButton } from "./action-button";
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
// ponytail: shared hover/tap wrapper — identical in both branches, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
type SocialProviderSectionProps = {
provider: AuthProvider;
name?: string;
@@ -37,15 +23,6 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
const account = getAccountByProviderId(provider);
const isConnected = hasAccount(provider);
const handleLink = useCallback(async () => {
await link(provider);
}, [link, provider]);
const handleUnlink = useCallback(async () => {
if (!account?.accountId) return;
await unlink(provider, account.accountId);
}, [account, unlink, provider]);
return (
<m.div
className="will-change-[transform,opacity]"
@@ -63,14 +40,19 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
<ActionButton>
{isConnected ? (
<Button variant="outline" onClick={handleUnlink}>
<Button
variant="outline"
onClick={() => {
if (account?.accountId) void unlink(provider, account.accountId);
}}
>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
) : (
<Button variant="outline" onClick={handleLink}>
<Button variant="outline" onClick={() => void link(provider)}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
@@ -1,27 +1,13 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useCallback } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useDialogStore } from "@/dialogs/store";
import { authClient } from "@/libs/auth/client";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
// ponytail: shared hover/tap wrapper — identical in both branches, match(boolean) removed
function ActionButton({ children }: { children: React.ReactNode }) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
export function TwoFactorSection() {
const { openDialog } = useDialogStore();
const { hasAccount } = useAuthAccounts();
@@ -30,14 +16,6 @@ export function TwoFactorSection() {
const hasPassword = hasAccount("credential");
const hasTwoFactor = session?.user.twoFactorEnabled ?? false;
const handleTwoFactorAction = useCallback(() => {
if (hasTwoFactor) {
openDialog("auth.two-factor.disable", undefined);
} else {
openDialog("auth.two-factor.enable", undefined);
}
}, [hasTwoFactor, openDialog]);
if (!hasPassword) return null;
return (
@@ -56,7 +34,10 @@ export function TwoFactorSection() {
</h2>
<ActionButton>
<Button variant="outline" onClick={handleTwoFactorAction}>
<Button
variant="outline"
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
>
{hasTwoFactor ? (
<>
<ToggleLeftIcon />
@@ -6,12 +6,13 @@ import { orpc } from "@/libs/orpc/client";
* Replaces the predicate that was duplicated across the import dialog, agent setup, and AI settings.
*/
export function useHasUsableAiProvider() {
const { data: providers, isLoading } = useQuery(orpc.aiProviders.list.queryOptions());
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const usableProviders = (providers ?? []).filter((provider) => provider.enabled && provider.testStatus === "success");
return {
error,
hasUsableProvider: usableProviders.length > 0,
usableProviders,
isLoading,
usableProviders,
};
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function ThemeCombobox(props: Props) {
keywords: [i18n.t(label)],
}));
const onThemeChange = async (value: string | null) => {
const onThemeChange = (value: string | null) => {
if (!value || !isTheme(value)) return;
setTheme(value);
void router.invalidate();
+2 -9
View File
@@ -22,20 +22,13 @@ import {
import { useTheme } from "@/features/theme/provider";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { changeLocale, localeMap } from "@/libs/locale";
import { isTheme } from "@/libs/theme";
type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
@@ -88,7 +81,7 @@ export function UserDropdownMenu({ children }: Props) {
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={changeLocale}>
{Object.entries(localeMap).map(([value, label]) => (
<DropdownMenuRadioItem key={value} value={value}>
{i18n.t(label)}
+5 -5
View File
@@ -24,7 +24,7 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Are you sure?");
});
expect(promise).toBeInstanceOf(Promise);
@@ -34,7 +34,7 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Heading");
});
@@ -44,7 +44,7 @@ describe("useConfirm", () => {
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
const cancel = buttons.find((b) => /cancel/i.test(b.textContent ?? ""));
await act(async () => {
await act(() => {
(cancelBtn as HTMLButtonElement | null)?.click() ?? cancel?.click();
});
@@ -55,14 +55,14 @@ describe("useConfirm", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise!: Promise<boolean>;
await act(async () => {
await act(() => {
promise = result.current("Heading", { confirmText: "Yes" });
});
const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"));
const yes = buttons.find((b) => /yes/i.test(b.textContent ?? ""));
await act(async () => {
await act(() => {
yes?.click();
});
+1 -1
View File
@@ -43,7 +43,7 @@ export function ConfirmDialogProvider({ children }: ConfirmDialogProviderProps)
cancelText: undefined,
});
const confirm = React.useCallback(async (title: string, options?: ConfirmOptions): Promise<boolean> => {
const confirm = React.useCallback((title: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setState({
open: true,
+1 -1
View File
@@ -48,7 +48,7 @@ export function useFormBlocker<TStore extends BlockableFormStore>(
return isDirty && !isSubmitting;
}, [isDirty, isSubmitting]);
const confirmClose = useCallback(async () => {
const confirmClose = useCallback(() => {
if (!shouldBlock()) return true;
return confirm(t`Are you sure you want to close this dialog?`, {
+6 -6
View File
@@ -34,11 +34,11 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Name?");
});
await act(async () => {
await act(() => {
clickButton(/cancel/i);
});
@@ -49,11 +49,11 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Name?", { defaultValue: "Initial" });
});
await act(async () => {
await act(() => {
clickButton(/confirm/i);
});
@@ -64,7 +64,7 @@ describe("usePrompt", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise!: Promise<string | null>;
await act(async () => {
await act(() => {
promise = result.current("Heading", { defaultValue: "preset" });
});
@@ -72,7 +72,7 @@ describe("usePrompt", () => {
const input = document.body.querySelector("input") as HTMLInputElement | null;
expect(input?.value).toBe("preset");
await act(async () => {
await act(() => {
clickButton(/confirm/i);
});
+1 -1
View File
@@ -67,7 +67,7 @@ export function PromptDialogProvider({ children }: PromptDialogProviderProps) {
return () => window.clearTimeout(timeoutId);
}, [state.open]);
const prompt = React.useCallback(async (title: string, options?: PromptOptions): Promise<string | null> => {
const prompt = React.useCallback((title: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
setState({
open: true,
+21 -25
View File
@@ -13,28 +13,24 @@ import {
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
const getAuthClient = () => {
return createAuthClient({
plugins: [
dashClient(),
adminClient(),
apiKeyClient(),
passkeyClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
// Redirect to 2FA verification page
if (typeof window !== "undefined") {
window.location.href = "/auth/verify-2fa";
}
},
}),
genericOAuthClient(),
oauthProviderClient(),
oauthProviderResourceClient(),
inferAdditionalFields<typeof auth>(),
],
});
};
export const authClient = getAuthClient();
export const authClient = createAuthClient({
plugins: [
dashClient(),
adminClient(),
apiKeyClient(),
passkeyClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
// Redirect to 2FA verification page
if (typeof window !== "undefined") {
window.location.href = "/auth/verify-2fa";
}
},
}),
genericOAuthClient(),
oauthProviderClient(),
oauthProviderResourceClient(),
inferAdditionalFields<typeof auth>(),
],
});
+39 -2
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { isLocale, resolveLocale } from "./locale";
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import Cookies from "js-cookie";
import { changeLocale, formatRelativeTime, isLocale, resolveLocale } from "./locale";
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
Cookies.remove("locale");
});
describe("isLocale", () => {
it("returns true for known locale en-US", () => {
@@ -44,3 +53,31 @@ describe("resolveLocale", () => {
expect(resolveLocale("")).toBe("en-US");
});
});
describe("formatRelativeTime", () => {
it("selects the largest matching unit", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-02T12:00:00Z"));
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("2026-01-02T10:00:00Z", formatter)).toBe("2 hours ago");
expect(formatRelativeTime("2026-01-02T11:59:45Z", formatter)).toBe("now");
});
it("uses the requested fallback for an invalid date", () => {
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
expect(formatRelativeTime("invalid", formatter, "")).toBe("");
});
});
describe("changeLocale", () => {
it("persists a valid locale and reloads", () => {
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => undefined);
changeLocale("de-DE");
expect(Cookies.get("locale")).toBe("de-DE");
expect(reload).toHaveBeenCalledOnce();
});
});
+26 -4
View File
@@ -10,6 +10,14 @@ export { isRTL };
const storageKey = "locale";
const defaultLocale: Locale = "en-US";
const messageLoaders = import.meta.glob<{ messages: Messages }>("../../locales/*.po");
const relativeTimeDivisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
export const localeMap = {
"af-ZA": msg`Afrikaans`,
@@ -77,16 +85,24 @@ export const resolveLocale = (locale: string): Locale => {
return isLocale(locale) ? locale : defaultLocale;
};
export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat, invalidFallback?: string) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
if (Number.isNaN(diffMs)) return invalidFallback ?? formatter.format(0, "second");
const division = relativeTimeDivisions.find((candidate) => Math.abs(diffMs) >= candidate.amount);
return division
? formatter.format(Math.round(diffMs / division.amount), division.unit)
: formatter.format(0, "second");
}
export const getLocale = () => {
const locale = Cookies.get(storageKey);
if (!locale || !isLocale(locale)) return defaultLocale;
return locale;
};
export const setLocaleCookie = (locale: Locale) => {
Cookies.set(storageKey, locale);
};
const loadMessages = async (locale: Locale) => {
const load = messageLoaders[`../../locales/${locale}.po`];
@@ -113,3 +129,9 @@ export const loadLocale = async (locale: string) => {
const { locale: resolvedLocale, messages } = await getLocaleMessages(locale);
i18n.loadAndActivate({ locale: resolvedLocale, messages });
};
export const changeLocale = (value: string | null) => {
if (!value || !isLocale(value)) return;
Cookies.set(storageKey, value);
window.location.reload();
};
+8 -16
View File
@@ -10,8 +10,8 @@ const getRpcUrl = () => {
return `${window.location.origin}/api/rpc`;
};
const createRpcClient = (): RouterClient<typeof router> => {
const link = new RPCLink({
export const client: RouterClient<typeof router> = createORPCClient(
new RPCLink({
url: getRpcUrl(),
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
plugins: [
@@ -26,15 +26,11 @@ const createRpcClient = (): RouterClient<typeof router> => {
console.warn("[oRPC client]", error);
}),
],
});
}),
);
return createORPCClient(link);
};
export const client = createRpcClient();
const createStreamClient = (): RouterClient<typeof router> => {
const link = new RPCLink({
export const streamClient: RouterClient<typeof router> = createORPCClient(
new RPCLink({
url: getRpcUrl(),
fetch: (request, init) => fetch(request, { ...init, credentials: "include" }),
interceptors: [
@@ -43,12 +39,8 @@ const createStreamClient = (): RouterClient<typeof router> => {
console.warn("[oRPC stream client]", error);
}),
],
});
return createORPCClient(link);
};
export const streamClient = createStreamClient();
}),
);
export const orpc = createTanstackQueryUtils(client);
@@ -6,7 +6,7 @@ import { createSectionTitleResolver } from "./section-title";
const resolverCache = new Map<string, Promise<SectionTitleResolver>>();
export const createSectionTitleResolverForLocale = async (localeParam: string) => {
export const createSectionTitleResolverForLocale = (localeParam: string) => {
const requestedLocale = resolveLocale(localeParam);
const cachedResolver = resolverCache.get(requestedLocale);
+84
View File
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import type { Website } from "@reactive-resume/schema/resume/data";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useAppForm } from "./tanstack-form";
vi.mock("@/components/input/url-input", () => ({
URLInput: ({
value,
onChange,
hideLabelButton,
}: {
value: Website;
onChange: (value: Website) => void;
hideLabelButton?: boolean;
}) => (
<input
aria-label="Website value"
data-hide-label-button={hideLabelButton}
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value })}
/>
),
}));
vi.mock("@/components/input/rich-input", () => ({
RichInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea aria-label="Description value" value={value} onChange={(event) => onChange(event.target.value)} />
),
}));
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
function TestForm() {
const form = useAppForm({
defaultValues: {
description: "Initial description",
website: { url: "https://example.com", label: "Example", inlineLink: false },
},
});
return (
<I18nProvider i18n={i18n}>
<form.AppField
name="website"
validators={{ onChange: ({ value }) => (value.url ? undefined : "Website is required") }}
>
{(field) => <field.WebsiteField label="Website" hideLabelButton formItemClassName="website-field" />}
</form.AppField>
<form.AppField
name="description"
validators={{ onChange: ({ value }) => (value ? undefined : "Description is required") }}
>
{(field) => <field.RichTextField label="Description" formItemClassName="description-field" />}
</form.AppField>
</I18nProvider>
);
}
describe("registered resume fields", () => {
it("preserves labels, layout classes, attributes, values, and errors", async () => {
const user = userEvent.setup();
const { container } = render(<TestForm />);
expect(screen.getByText("Website")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(container.querySelector(".website-field")).toBeInTheDocument();
expect(container.querySelector(".description-field")).toBeInTheDocument();
expect(screen.getByLabelText("Website value")).toHaveAttribute("data-hide-label-button", "true");
await user.clear(screen.getByLabelText("Website value"));
await user.clear(screen.getByLabelText("Description value"));
expect(screen.getByText("Website is required")).toBeInTheDocument();
expect(screen.getByText("Description is required")).toBeInTheDocument();
});
});
+45 -1
View File
@@ -1,8 +1,11 @@
import type { Website } from "@reactive-resume/schema/resume/data";
import type * as React from "react";
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { InputGroupInput } from "@reactive-resume/ui/components/input-group";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
type FieldFrameProps = {
label?: React.ReactNode;
@@ -25,6 +28,17 @@ type NumberFieldProps = FieldFrameProps &
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "type" | "value"
>;
type WebsiteFieldProps = {
label: React.ReactNode;
formItemClassName?: string;
hideLabelButton: boolean;
};
type RichTextFieldProps = {
label: React.ReactNode;
formItemClassName?: string;
};
const { fieldContext, formContext, useFieldContext } = createFormHookContexts();
function TextField({ label, description, formItemClassName, ...props }: TextFieldProps) {
@@ -103,8 +117,38 @@ function NumberField({ label, description, formItemClassName, ...props }: Number
);
}
function WebsiteField({ label, formItemClassName, hideLabelButton }: WebsiteFieldProps) {
const field = useFieldContext<Website>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
<FormLabel>{label}</FormLabel>
<URLInput
value={field.state.value}
onChange={(value) => field.handleChange(value)}
hideLabelButton={hideLabelButton}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
);
}
function RichTextField({ label, formItemClassName }: RichTextFieldProps) {
const field = useFieldContext<string>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
<FormLabel>{label}</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(value) => field.handleChange(value)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
);
}
export const { useAppForm, withForm } = createFormHook({
fieldComponents: { InputGroupTextField, NumberField, TextField },
fieldComponents: { InputGroupTextField, NumberField, RichTextField, TextField, WebsiteField },
fieldContext,
formComponents: {},
formContext,
+2 -3
View File
@@ -16,7 +16,7 @@ import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { Toaster } from "@reactive-resume/ui/components/sonner";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
@@ -45,6 +45,7 @@ const tagline = "A free and open-source resume builder";
const title = `${appName}${tagline}`;
const description =
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
const iconContextValue: IconProps = { size: 16, weight: "regular" };
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@@ -107,8 +108,6 @@ function RootComponent() {
// Suppress the app-wide donation toast inside the builder so it doesn't cover the right-sidebar controls.
const isBuilder = useRouterState({ select: (s) => s.location.pathname.startsWith("/builder") });
const iconContextValue = useMemo<IconProps>(() => ({ size: 16, weight: "regular" }), []);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
@@ -1,7 +1,6 @@
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
import { Trans } from "@lingui/react/macro";
import { m } from "motion/react";
import { useMemo } from "react";
import { templates } from "@/dialogs/resume/template/data";
type TemplateItemProps = {
@@ -75,21 +74,12 @@ const createMarqueeItems = (entries: Array<[string, TemplateMetadata]>, rowId: s
{ id: `${rowId}-${template}-repeat`, metadata },
]);
const templateEntries = Object.entries(templates);
const halfway = Math.ceil(templateEntries.length / 2);
const row1 = createMarqueeItems(templateEntries.slice(0, halfway), "row1");
const row2 = createMarqueeItems(templateEntries.slice(halfway), "row2");
export function Templates() {
// Split templates into two rows and duplicate for seamless infinite scroll
const { row1, row2 } = useMemo(() => {
const entries = Object.entries(templates);
const half = Math.ceil(entries.length / 2);
const firstHalf = entries.slice(0, half);
const secondHalf = entries.slice(half);
// Duplicate each row for seamless scrolling
return {
row1: createMarqueeItems(firstHalf, "row1"),
row2: createMarqueeItems(secondHalf, "row2"),
};
}, []);
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<m.div
@@ -1,7 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { QuotesIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useMemo } from "react";
const email = "hello@amruthpillai.com";
@@ -70,6 +69,11 @@ type TestimonialColumnData = {
testimonials: string[];
};
const testimonialColumns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
testimonialColumns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
type MarqueeMasonryProps = {
columns: TestimonialColumnData[];
direction: "left" | "right";
@@ -97,16 +101,6 @@ function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryPro
}
export function Testimonials() {
const columns = useMemo(() => {
const columns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
columns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
return columns;
}, []);
return (
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
<m.div
@@ -145,7 +139,7 @@ export function Testimonials() {
{/* Right fade */}
<div className="pointer-events-none absolute inset-e-0 top-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
<MarqueeMasonry columns={columns} direction="left" duration={60} />
<MarqueeMasonry columns={testimonialColumns} direction="left" duration={60} />
</div>
</section>
);
@@ -4,7 +4,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -12,6 +12,7 @@ import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -34,20 +35,12 @@ function isAgentConfigError(error: unknown) {
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const isClient = useIsClient();
const navigate = useNavigate();
const {
data: providers,
isLoading: isLoadingProviders,
error: providersError,
} = useQuery(orpc.aiProviders.list.queryOptions());
const { usableProviders, isLoading: isLoadingProviders, error: providersError } = useHasUsableAiProvider();
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
);
const { mutate: createThread, isPending } = useMutation(orpc.agent.threads.create.mutationOptions());
const usableProviders = useMemo(
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderIdOverride, setAiProviderIdOverride] = useState<string | null | undefined>(undefined);
const [sourceResumeIdOverride, setSourceResumeIdOverride] = useState<string | null | undefined>(undefined);
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
@@ -25,6 +25,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
@@ -41,28 +42,6 @@ type AgentThreadSidebarProps = {
className?: string;
};
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 60_000) return formatter.format(0, "second");
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
function ThreadActions({ thread, activeThreadId }: ThreadActionsProps) {
const navigate = useNavigate();
const confirm = useConfirm();
@@ -164,7 +143,7 @@ function ThreadRow({ thread, activeThreadId }: ThreadRowProps) {
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter, "")}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
+1 -1
View File
@@ -3,7 +3,7 @@ import { createNoindexFollowMeta } from "@/libs/seo";
export const Route = createFileRoute("/agent")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { ForgotPasswordPage } from "@/features/auth/pages/forgot-password";
export const Route = createFileRoute("/auth/forgot-password")({
component: ForgotPasswordPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/auth/")({
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
throw redirect({ to: "/auth/login", replace: true });
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { LoginPage } from "@/features/auth/pages/login";
export const Route = createFileRoute("/auth/login")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
return { session: null };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { RegisterPage } from "@/features/auth/pages/register";
export const Route = createFileRoute("/auth/register")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", replace: true });
return { session: null };
+1 -1
View File
@@ -7,7 +7,7 @@ const searchSchema = z.object({ token: z.string().min(1) });
export const Route = createFileRoute("/auth/reset-password")({
component: RouteComponent,
validateSearch: searchSchema,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
onError: (error) => {
@@ -1,9 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa-backup";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa-backup")({
component: VerifyTwoFactorBackupPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { VerifyTwoFactorPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa")({
component: VerifyTwoFactorPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
@@ -15,7 +15,6 @@ import {
import { useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
@@ -72,15 +71,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
redo();
});
const publicUrl = useMemo(() => {
if (!session?.user.username || !resumeSlug) return "";
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
}, [session?.user.username, resumeSlug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const publicUrl =
session?.user.username && resumeSlug ? `${window.location.origin}/${session.user.username}/${resumeSlug}` : "";
return (
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
@@ -111,7 +103,14 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon
icon={LinkSimpleIcon}
title={t`Copy URL`}
onClick={async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}}
/>
</m.div>
</div>
);
@@ -19,29 +19,9 @@ import {
import { useResumeStore } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
// No division matches only when the gap is under a minute (the smallest division), so fall back to seconds.
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return formatter.format(0, "second");
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
type BuilderVersionHistoryProps = {
resumeId: string;
};
@@ -326,7 +326,6 @@ describe("CustomStylesSectionBuilder", () => {
fireEvent.blur(fontSizeInput);
expect(fontSizeInput).toHaveValue(12);
expect(updateResumeData).toHaveBeenCalledTimes(2);
});
it("commits normalized legacy values when the input loses focus", () => {
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -93,13 +92,8 @@ export function ResumeAnalysisSectionBuilder() {
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
if (score == null) return "bg-muted";
if (score >= 80) return "bg-emerald-600";
if (score >= 60) return "bg-amber-600";
return "bg-rose-600";
}, [score]);
const scoreTone =
score == null ? "bg-muted" : score >= 80 ? "bg-emerald-600" : score >= 60 ? "bg-amber-600" : "bg-rose-600";
const onAnalyze = () => {
if (!resume) return;
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
@@ -29,10 +29,7 @@ export function SharingSectionBuilder() {
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const publicUrl = useMemo(() => {
if (!session) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session, resume]);
const publicUrl = session ? `${window.location.origin}/${session.user.username}/${resume.slug}` : "";
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
@@ -1,6 +1,6 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
@@ -135,17 +135,14 @@ export function useBuilderSidebar(): UseBuilderSidebarReturn {
[expandSize],
);
// ponytail: memoized but callers destructure; selector removed (state rebuilt every render, zero benefit)
return useMemo(() => {
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}, [maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior, isCollapsed, toggleSidebar]);
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}
export const setBuilderLayout = (data: BuilderLayout) => {
@@ -12,7 +12,7 @@ import { getBuilderLayout } from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -19,10 +16,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = {
};
export function ResumeContextMenu({ resume, children }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<ContextMenu>
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,10 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children">
};
export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<DropdownMenu>
@@ -0,0 +1,54 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type Resume = RouterOutput["resume"]["list"][number];
export function useResumeMenuActions(resume: Resume) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmed = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmed) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{ onError: (error) => toast.error(getResumeErrorMessage(error)) },
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmed) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => toast.success(t`Your resume has been deleted successfully.`, { id: toastId }),
onError: (error) => toast.error(getResumeErrorMessage(error), { id: toastId }),
},
);
};
return {
handleDelete,
handleDuplicate: () => openDialog("resume.duplicate", resume),
handleToggleLock,
handleUpdate: () => openDialog("resume.update", resume),
};
}
+2 -2
View File
@@ -7,11 +7,11 @@ import { DashboardSidebar } from "./-components/sidebar";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async () => {
loader: () => {
const sidebarState = getDashboardSidebarState();
return { sidebarState };
},
+2 -1
View File
@@ -53,7 +53,8 @@
},
"suspicious": {
"noArrayIndexKey": "off",
"noExplicitAny": "error"
"noExplicitAny": "error",
"useAwait": "error"
},
"nursery": {
"useSortedClasses": {
+1 -1
View File
@@ -88,7 +88,7 @@ export const publicProcedure = base.use(async ({ context, next }) => {
});
});
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
export const protectedProcedure = publicProcedure.use(({ context, next }) => {
if (!context.user) throw new ORPCError("UNAUTHORIZED");
return next({
+1 -1
View File
@@ -19,7 +19,7 @@ describe("applicationDto sourceUrl", () => {
role: "Engineer",
sourceUrl: "javascript:alert(1)",
}),
).toThrow();
).toThrow("URL must use http or https.");
});
});
+1 -8
View File
@@ -22,14 +22,7 @@ const applicationDocumentFileSchema = z
const httpUrlSchema = z
.string()
.trim()
.refine((value) => {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, "URL must use http or https.");
.pipe(z.url({ protocol: /^https?$/, error: "URL must use http or https." }));
const applicationSchema = createSelectSchema(schema.application, {
id: z.string().describe("The ID of the application."),
+1 -3
View File
@@ -14,7 +14,5 @@ export const actionsRouter = {
})
.input(z.object({ id: z.string() }))
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.actions.revert({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => agentService.actions.revert({ id: input.id, userId: context.user.id })),
};
+6 -12
View File
@@ -4,10 +4,6 @@ import { storageUploadRateLimit } from "../../middleware/rate-limit";
import { mapAgentEnvironmentError } from "./routing";
import { agentService } from "./service";
function base64ToUint8Array(value: string) {
return Uint8Array.from(Buffer.from(value, "base64"));
}
export const attachmentsRouter = {
create: protectedProcedure
.route({
@@ -27,15 +23,15 @@ export const attachmentsRouter = {
)
.use(storageUploadRateLimit)
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.attachments.create({
.handler(({ context, input }) =>
agentService.attachments.create({
userId: context.user.id,
threadId: input.threadId,
filename: input.filename,
mediaType: input.mediaType,
data: base64ToUint8Array(input.data),
});
}),
data: Uint8Array.from(Buffer.from(input.data, "base64")),
}),
),
delete: protectedProcedure
.route({
@@ -48,7 +44,5 @@ export const attachmentsRouter = {
.input(z.object({ id: z.string() }))
.output(z.void())
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
await agentService.attachments.delete({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => agentService.attachments.delete({ id: input.id, userId: context.user.id })),
};
+11 -11
View File
@@ -23,14 +23,14 @@ export const messagesRouter = {
)
.use(aiRequestRateLimit)
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.messages.send({
.handler(({ context, input }) =>
agentService.messages.send({
userId: context.user.id,
threadId: input.threadId,
message: input.message,
...(input.attachmentIds ? { attachmentIds: input.attachmentIds } : {}),
});
}),
}),
),
stop: protectedProcedure
.route({
@@ -48,13 +48,13 @@ export const messagesRouter = {
)
.output(z.void())
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
await agentService.messages.stop({
.handler(({ context, input }) =>
agentService.messages.stop({
userId: context.user.id,
threadId: input.threadId,
...(input.partialMessage ? { partialMessage: input.partialMessage } : {}),
});
}),
}),
),
resume: protectedProcedure
.route({
@@ -66,7 +66,7 @@ export const messagesRouter = {
})
.input(z.object({ threadId: z.string() }))
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.messages.resume({ userId: context.user.id, threadId: input.threadId });
}),
.handler(({ context, input }) =>
agentService.messages.resume({ userId: context.user.id, threadId: input.threadId }),
),
};
+5 -16
View File
@@ -332,20 +332,11 @@ function uniqueAttachmentIds(ids: unknown) {
throw new ORPCError("BAD_REQUEST", { message: "Attachment IDs must be unique." });
}
if (unique.size > MAX_ATTACHMENTS_PER_MESSAGE) {
throw new ORPCError("BAD_REQUEST", { message: "Too many attachments for one message." });
}
return Array.from(unique);
}
function normalizeAttachmentIds(ids: unknown) {
const unique = uniqueAttachmentIds(ids);
return unique;
return [...unique];
}
async function getUnlinkedMessageAttachments(input: { ids: unknown; threadId: string; userId: string }) {
const ids = normalizeAttachmentIds(input.ids);
const ids = uniqueAttachmentIds(input.ids);
if (ids.length === 0) return [];
const attachments = await db
@@ -406,9 +397,9 @@ async function linkAttachmentsToMessage(input: {
}
}
async function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]): Promise<AttachmentModelInput[]> {
function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]): Promise<AttachmentModelInput[]> {
const storage = getStorageService();
const inputs = await Promise.all(
return Promise.all(
attachments.map(async (attachment) => {
const stored = await storage.read(attachment.storageKey);
if (!stored) {
@@ -418,8 +409,6 @@ async function readAttachmentModelInputs(attachments: AgentAttachmentRecord[]):
return { attachment, data: stored.data };
}),
);
return inputs;
}
function attachModelPartsToLatestUserMessage(
@@ -643,7 +632,7 @@ function buildThreadTitle(message: UIMessage, fallback: string) {
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
}
async function listThreadMessages(input: { threadId: string; userId: string }) {
function listThreadMessages(input: { threadId: string; userId: string }) {
return db
.select()
.from(schema.agentMessage)
+12 -20
View File
@@ -13,9 +13,7 @@ export const threadsRouter = {
summary: "List agent threads",
})
.use(mapAgentEnvironmentError)
.handler(async ({ context }) => {
return await agentService.threads.list({ userId: context.user.id });
}),
.handler(({ context }) => agentService.threads.list({ userId: context.user.id })),
create: protectedProcedure
.route({
@@ -27,14 +25,14 @@ export const threadsRouter = {
})
.input(z.object({ aiProviderId: z.string().optional(), sourceResumeId: z.string().optional() }))
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.threads.create({
.handler(({ context, input }) =>
agentService.threads.create({
userId: context.user.id,
locale: context.locale,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
...(input.sourceResumeId ? { sourceResumeId: input.sourceResumeId } : {}),
});
}),
}),
),
getOrCreateForResume: protectedProcedure
.route({
@@ -46,13 +44,13 @@ export const threadsRouter = {
})
.input(z.object({ resumeId: z.string(), aiProviderId: z.string().optional() }))
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.threads.getOrCreateForResume({
.handler(({ context, input }) =>
agentService.threads.getOrCreateForResume({
userId: context.user.id,
resumeId: input.resumeId,
...(input.aiProviderId ? { aiProviderId: input.aiProviderId } : {}),
});
}),
}),
),
get: protectedProcedure
.route({
@@ -64,9 +62,7 @@ export const threadsRouter = {
})
.input(z.object({ id: z.string() }))
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
return await agentService.threads.get({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => agentService.threads.get({ id: input.id, userId: context.user.id })),
archive: protectedProcedure
.route({
@@ -79,9 +75,7 @@ export const threadsRouter = {
.input(z.object({ id: z.string() }))
.output(z.void())
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
await agentService.threads.archive({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => agentService.threads.archive({ id: input.id, userId: context.user.id })),
delete: protectedProcedure
.route({
@@ -94,7 +88,5 @@ export const threadsRouter = {
.input(z.object({ id: z.string() }))
.output(z.void())
.use(mapAgentEnvironmentError)
.handler(async ({ context, input }) => {
await agentService.threads.delete({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => agentService.threads.delete({ id: input.id, userId: context.user.id })),
};
@@ -7,16 +7,6 @@ import { aiRequestRateLimit } from "../../middleware/rate-limit";
import { providerInput, updateProviderInput } from "./inputs";
import { aiProvidersService } from "./service";
function isAgentEnvironmentUnavailable(error: unknown) {
return error instanceof Error && error.message === "AGENT_ENVIRONMENT_UNAVAILABLE";
}
function throwUnavailable(): never {
throw new ORPCError("PRECONDITION_FAILED", {
message: "AI agent workspace is unavailable because REDIS_URL or ENCRYPTION_SECRET is not configured.",
});
}
function isInvalidAiBaseUrl(error: unknown) {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
@@ -39,14 +29,7 @@ export const aiProvidersRouter = {
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context }) => {
try {
return await aiProvidersService.list({ userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
.handler(({ context }) => aiProvidersService.list({ userId: context.user.id })),
create: protectedProcedure
.route({
@@ -74,7 +57,6 @@ export const aiProvidersRouter = {
apiKey: input.apiKey,
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
@@ -110,7 +92,6 @@ export const aiProvidersRouter = {
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
});
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
throw error;
}
@@ -130,14 +111,7 @@ export const aiProvidersRouter = {
.errors({
PRECONDITION_FAILED: { message: "AI agent workspace is not configured.", status: 412 },
})
.handler(async ({ context, input }) => {
try {
await aiProvidersService.delete({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
throw error;
}
}),
.handler(({ context, input }) => aiProvidersService.delete({ id: input.id, userId: context.user.id })),
test: protectedProcedure
.route({
@@ -161,7 +135,6 @@ export const aiProvidersRouter = {
try {
return await aiProvidersService.test({ id: input.id, userId: context.user.id });
} catch (error) {
if (isAgentEnvironmentUnavailable(error)) throwUnavailable();
if (isInvalidAiBaseUrl(error)) throwInvalidProviderConfig();
if (error instanceof ORPCError) throw error;
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
@@ -86,10 +86,6 @@ function normalizeBaseUrl(input: { provider: AIProvider; baseURL?: string | null
return resolveAiBaseUrl({ provider: input.provider, baseURL: trimmed });
}
function orderByLastUsedAtDescNullsLast() {
return desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`);
}
async function getOwnedProvider(input: { id: string; userId: string }) {
const [provider] = await db
.select()
@@ -110,7 +106,10 @@ export const aiProvidersService = {
.select()
.from(schema.aiProvider)
.where(eq(schema.aiProvider.userId, input.userId))
.orderBy(orderByLastUsedAtDescNullsLast(), asc(schema.aiProvider.createdAt));
.orderBy(
desc(sql<Date>`coalesce(${schema.aiProvider.lastUsedAt}, '1970-01-01T00:00:00.000Z'::timestamptz)`),
asc(schema.aiProvider.createdAt),
);
return providers.map(toResponse);
},
@@ -250,7 +249,7 @@ export const aiProvidersService = {
if (!updated) throw new ORPCError("NOT_FOUND");
return toResponse(updated);
} catch (error) {
const [updated] = await db
await db
.update(schema.aiProvider)
.set({
enabled: false,
@@ -258,10 +257,8 @@ export const aiProvidersService = {
testError: error instanceof Error ? error.message : "Failed to test provider.",
lastTestedAt: new Date(),
})
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)))
.returning();
.where(and(eq(schema.aiProvider.id, input.id), eq(schema.aiProvider.userId, input.userId)));
if (!updated) throw error;
throw error;
}
},
+1 -3
View File
@@ -80,9 +80,7 @@ export function supportsOpenAIWebSearch(model: string) {
if (OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS.has(normalized)) return true;
return Array.from(OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS).some((modelId) =>
isDateSnapshotForModel(normalized, modelId),
);
return [...OPENAI_WEB_SEARCH_RESPONSES_MODEL_IDS].some((modelId) => isDateSnapshotForModel(normalized, modelId));
}
export function supportsProviderNativeWebSearch(provider: AiProviderCapabilityInput) {
+1 -1
View File
@@ -15,7 +15,7 @@ afterEach(() => {
function stubOpenAICompatibleResponse(response?: { content?: string; finishReason?: string }) {
let requestBody: unknown;
const fetchMock = vi.fn(async (_input: unknown, init?: { body?: unknown }) => {
const fetchMock = vi.fn((_input: unknown, init?: { body?: unknown }) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { max_tokens?: number };
requestBody = body;
const hasEnoughOutputTokens = (body.max_tokens ?? 0) >= 128;
+1 -1
View File
@@ -361,7 +361,7 @@ async function chat(input: ChatInput) {
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
inputSchema: resumePatchProposalToolInputSchema,
outputSchema: resumePatchProposalToolOutputSchema,
execute: async (toolInput) => {
execute: (toolInput) => {
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
for (const proposal of proposals) {
+1 -1
View File
@@ -348,7 +348,7 @@ async function fetchLinkedInJobPostingText(jobId: string): Promise<string> {
// Best-effort fetch + strip of a job posting page. http(s) only, size/time capped.
export async function fetchJobPostingText(url: string): Promise<string> {
const jobId = linkedInJobId(url);
if (jobId) return await fetchLinkedInJobPostingText(jobId);
if (jobId) return fetchLinkedInJobPostingText(jobId);
if (isLinkedInUrl(url)) {
throw new ORPCError("BAD_REQUEST", { message: "The LinkedIn job URL must include a job posting ID." });
}
+26 -43
View File
@@ -17,14 +17,14 @@ export const crudRouter = {
})
.input(applicationDto.list.input)
.output(applicationDto.list.output)
.handler(async ({ input, context }) => {
return applicationService.list({
.handler(({ input, context }) =>
applicationService.list({
userId: context.user.id,
...(input.status ? { status: input.status } : {}),
...(input.tags ? { tags: input.tags } : {}),
includeArchived: input.includeArchived,
});
}),
}),
),
getById: protectedProcedure
.route({
@@ -39,9 +39,7 @@ export const crudRouter = {
})
.input(applicationDto.getById.input)
.output(applicationDto.getById.output)
.handler(async ({ input, context }) => {
return applicationService.getById({ id: input.id, userId: context.user.id });
}),
.handler(({ input, context }) => applicationService.getById({ id: input.id, userId: context.user.id })),
create: protectedProcedure
.route({
@@ -57,9 +55,7 @@ export const crudRouter = {
.input(applicationDto.create.input)
.use(resumeMutationRateLimit)
.output(applicationDto.create.output)
.handler(async ({ input, context }) => {
return applicationService.create({ userId: context.user.id, ...input });
}),
.handler(({ input, context }) => applicationService.create({ userId: context.user.id, ...input })),
import: protectedProcedure
.route({
@@ -75,9 +71,7 @@ export const crudRouter = {
.input(applicationDto.import.input)
.use(resumeMutationRateLimit)
.output(applicationDto.import.output)
.handler(async ({ input, context }) => {
return applicationService.importMany({ userId: context.user.id, items: input.items });
}),
.handler(({ input, context }) => applicationService.importMany({ userId: context.user.id, items: input.items })),
update: protectedProcedure
.route({
@@ -93,9 +87,7 @@ export const crudRouter = {
.input(applicationDto.update.input)
.use(resumeMutationRateLimit)
.output(applicationDto.update.output)
.handler(async ({ input, context }) => {
return applicationService.update({ userId: context.user.id, ...input });
}),
.handler(({ input, context }) => applicationService.update({ userId: context.user.id, ...input })),
attachDocument: protectedProcedure
.route({
@@ -153,9 +145,9 @@ export const crudRouter = {
.input(applicationDto.removeDocument.input)
.use(resumeMutationRateLimit)
.output(applicationDto.removeDocument.output)
.handler(async ({ input, context }) => {
return applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind });
}),
.handler(({ input, context }) =>
applicationService.removeDocument({ id: input.id, userId: context.user.id, kind: input.kind }),
),
addNote: protectedProcedure
.route({
@@ -170,9 +162,14 @@ export const crudRouter = {
.input(applicationDto.addNote.input)
.use(resumeMutationRateLimit)
.output(applicationDto.addNote.output)
.handler(async ({ input, context }) => {
return applicationService.addNote({ id: input.id, userId: context.user.id, text: input.text, date: input.date });
}),
.handler(({ input, context }) =>
applicationService.addNote({
id: input.id,
userId: context.user.id,
text: input.text,
date: input.date,
}),
),
updateTimelineEntry: protectedProcedure
.route({
@@ -187,9 +184,7 @@ export const crudRouter = {
.input(applicationDto.updateTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.updateTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.updateTimelineEntry({ ...input, userId: context.user.id });
}),
.handler(({ input, context }) => applicationService.updateTimelineEntry({ ...input, userId: context.user.id })),
deleteTimelineEntry: protectedProcedure
.route({
@@ -205,9 +200,7 @@ export const crudRouter = {
.input(applicationDto.deleteTimelineEntry.input)
.use(resumeMutationRateLimit)
.output(applicationDto.deleteTimelineEntry.output)
.handler(async ({ input, context }) => {
return applicationService.deleteTimelineEntry({ ...input, userId: context.user.id });
}),
.handler(({ input, context }) => applicationService.deleteTimelineEntry({ ...input, userId: context.user.id })),
delete: protectedProcedure
.route({
@@ -222,9 +215,7 @@ export const crudRouter = {
.input(applicationDto.delete.input)
.use(resumeMutationRateLimit)
.output(applicationDto.delete.output)
.handler(async ({ input, context }) => {
return applicationService.delete({ id: input.id, userId: context.user.id });
}),
.handler(({ input, context }) => applicationService.delete({ id: input.id, userId: context.user.id })),
bulkUpdate: protectedProcedure
.route({
@@ -240,9 +231,7 @@ export const crudRouter = {
.input(applicationDto.bulkUpdate.input)
.use(resumeMutationRateLimit)
.output(applicationDto.bulkUpdate.output)
.handler(async ({ input, context }) => {
return applicationService.bulkUpdate({ userId: context.user.id, ...input });
}),
.handler(({ input, context }) => applicationService.bulkUpdate({ userId: context.user.id, ...input })),
bulkDelete: protectedProcedure
.route({
@@ -257,9 +246,7 @@ export const crudRouter = {
.input(applicationDto.bulkDelete.input)
.use(resumeMutationRateLimit)
.output(applicationDto.bulkDelete.output)
.handler(async ({ input, context }) => {
return applicationService.bulkDelete({ userId: context.user.id, ids: input.ids });
}),
.handler(({ input, context }) => applicationService.bulkDelete({ userId: context.user.id, ids: input.ids })),
stats: protectedProcedure
.route({
@@ -273,9 +260,7 @@ export const crudRouter = {
})
.input(applicationDto.stats.input)
.output(applicationDto.stats.output)
.handler(async ({ context }) => {
return applicationService.stats({ userId: context.user.id });
}),
.handler(({ context }) => applicationService.stats({ userId: context.user.id })),
tags: protectedProcedure
.route({
@@ -288,7 +273,5 @@ export const crudRouter = {
successDescription: "Distinct tags.",
})
.output(applicationDto.tags.output)
.handler(async ({ context }) => {
return applicationService.listTags({ userId: context.user.id });
}),
.handler(({ context }) => applicationService.listTags({ userId: context.user.id })),
};
@@ -47,16 +47,12 @@ function noteEntry(text: string, date?: string): ApplicationTimelineEntry {
return { id: generateId(), type: "note", text, at: date ? atFromDateString(date) : new Date() };
}
function byNewest(a: ApplicationTimelineEntry, b: ApplicationTimelineEntry) {
return new Date(b.at).getTime() - new Date(a.at).getTime();
}
function timelineDay(value: Date | string) {
return timelineDate(value).toISOString().slice(0, 10);
}
function sortTimeline(activity: ApplicationTimelineEntry[]): ApplicationTimelineEntry[] {
return [...activity].sort(byNewest);
return [...activity].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
}
function currentStageAnchor(activity: ApplicationTimelineEntry[], status: ApplicationStatus) {
@@ -423,7 +419,7 @@ export const applicationService = {
return stripUserId(updated);
},
updateTimelineEntry: async (input: {
updateTimelineEntry: (input: {
id: string;
userId: string;
entryId: string;
@@ -474,7 +470,7 @@ export const applicationService = {
});
},
deleteTimelineEntry: async (input: { id: string; userId: string; entryId: string }) => {
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => {
return db.transaction(async (tx) => {
await tx.execute(sql`
select 1 from ${schema.application}
+3 -9
View File
@@ -15,9 +15,7 @@ export const authRouter = {
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
.handler((): ProviderList => authService.providers.list()),
},
exportData: protectedProcedure
@@ -31,9 +29,7 @@ export const authRouter = {
"Returns a JSON-serializable export of the authenticated user's data, including their public profile fields and all of their resumes. Secrets such as password hashes, tokens, and API keys are never included. Requires authentication.",
successDescription: "The user's exported account data.",
})
.handler(async ({ context }) => {
return await authService.exportData({ userId: context.user.id });
}),
.handler(({ context }) => authService.exportData({ userId: context.user.id })),
deleteAccount: protectedProcedure
.route({
@@ -46,7 +42,5 @@ export const authRouter = {
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
successDescription: "The user account and all associated data have been successfully deleted.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
}),
.handler(({ context }) => authService.deleteAccount({ userId: context.user.id })),
};
+1 -3
View File
@@ -17,7 +17,5 @@ export const analysisRouter = {
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => resumeService.analysis.getById({ id: input.id, userId: context.user.id })),
};
+22 -26
View File
@@ -19,13 +19,13 @@ export const crudRouter = {
})
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
.output(resumeDto.list.output)
.handler(async ({ input, context }) => {
return resumeService.list({
.handler(({ input, context }) =>
resumeService.list({
userId: context.user.id,
tags: input.tags,
sort: input.sort,
});
}),
}),
),
getById: protectedProcedure
.route({
@@ -40,9 +40,7 @@ export const crudRouter = {
})
.input(resumeDto.getById.input)
.output(resumeDto.getById.output)
.handler(async ({ context, input }) => {
return resumeService.getById({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => resumeService.getById({ id: input.id, userId: context.user.id })),
create: protectedProcedure
.route({
@@ -64,16 +62,16 @@ export const crudRouter = {
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.create({
.handler(({ context, input }) =>
resumeService.create({
name: input.name,
slug: input.slug,
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
});
}),
}),
),
import: protectedProcedure
.route({
@@ -139,8 +137,8 @@ export const crudRouter = {
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.update({
.handler(({ context, input }) =>
resumeService.update({
id: input.id,
userId: context.user.id,
...(input.name !== undefined ? { name: input.name } : {}),
@@ -148,8 +146,8 @@ export const crudRouter = {
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
});
}),
}),
),
patch: protectedProcedure
.route({
@@ -175,14 +173,14 @@ export const crudRouter = {
status: 409,
},
})
.handler(async ({ context, input }) => {
return resumeService.patch({
.handler(({ context, input }) =>
resumeService.patch({
id: input.id,
userId: context.user.id,
operations: input.operations,
...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}),
});
}),
}),
),
setLocked: protectedProcedure
.route({
@@ -198,13 +196,13 @@ export const crudRouter = {
.input(resumeDto.setLocked.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setLocked.output)
.handler(async ({ context, input }) => {
return resumeService.setLocked({
.handler(({ context, input }) =>
resumeService.setLocked({
id: input.id,
userId: context.user.id,
isLocked: input.isLocked,
});
}),
}),
),
duplicate: protectedProcedure
.route({
@@ -247,7 +245,5 @@ export const crudRouter = {
.input(resumeDto.delete.input)
.use(resumeMutationRateLimit)
.output(resumeDto.delete.output)
.handler(async ({ context, input }) => {
return resumeService.delete({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => resumeService.delete({ id: input.id, userId: context.user.id })),
};
+4 -4
View File
@@ -75,10 +75,10 @@ export const downloadResumePdfProcedure = protectedProcedure
}),
)
.use(pdfExportRateLimit)
.handler(async ({ context, input }) => {
return createResumePdfDownload({
.handler(({ context, input }) =>
createResumePdfDownload({
id: input.id,
userId: context.user.id,
...(input.target ? { target: input.target } : {}),
});
});
}),
);
+4 -8
View File
@@ -165,10 +165,7 @@ const tags = {
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
return sortedTags;
return [...new Set(result.flatMap((tag) => tag.tags))].sort((a, b) => a.localeCompare(b));
},
};
@@ -422,8 +419,8 @@ export const resumeService = {
},
},
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
list: (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) =>
db
.select({
id: schema.resume.id,
name: schema.resume.name,
@@ -449,8 +446,7 @@ export const resumeService = {
.with("createdAt", () => asc(schema.resume.createdAt))
.with("name", () => asc(schema.resume.name))
.exhaustive(),
);
},
),
getById: async (input: { id: string; userId: string }) => {
const [resume] = await db
+21 -20
View File
@@ -18,13 +18,13 @@ export const sharingRouter = {
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
.handler(({ input, context }) =>
resumeService.getBySlug({
...input,
requestHeaders: context.reqHeaders,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
}),
),
setPassword: protectedProcedure
.route({
@@ -40,13 +40,13 @@ export const sharingRouter = {
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
.handler(({ context, input }) =>
resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
}),
),
verifyPassword: publicProcedure
.route({
@@ -68,14 +68,15 @@ export const sharingRouter = {
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ context, input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
});
}),
.handler(
({ context, input }): Promise<boolean> =>
resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
...(context.resHeaders ? { responseHeaders: context.resHeaders } : {}),
}),
),
removePassword: protectedProcedure
.route({
@@ -91,10 +92,10 @@ export const sharingRouter = {
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
.handler(({ context, input }) =>
resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
}),
),
};
@@ -24,9 +24,7 @@ export const resumeStatisticsRouter = {
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
.handler(({ context, input }) => resumeService.statistics.getById({ id: input.id, userId: context.user.id })),
getDailyById: protectedProcedure
.route({
@@ -54,7 +52,11 @@ export const resumeStatisticsRouter = {
}),
),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getDailySeries({ id: input.id, userId: context.user.id, days: input.days });
}),
.handler(({ context, input }) =>
resumeService.statistics.getDailySeries({
id: input.id,
userId: context.user.id,
days: input.days,
}),
),
};

Some files were not shown because too many files have changed in this diff Show More