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