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