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