refactor: ponytail audit

This commit is contained in:
Amruth Pillai
2026-07-27 20:26:16 +02:00
parent bb1fb3a7d6
commit 9110e86997
157 changed files with 1082 additions and 2177 deletions
+2 -3
View File
@@ -16,7 +16,7 @@ import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { Toaster } from "@reactive-resume/ui/components/sonner";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
@@ -45,6 +45,7 @@ const tagline = "A free and open-source resume builder";
const title = `${appName}${tagline}`;
const description =
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
const iconContextValue: IconProps = { size: 16, weight: "regular" };
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@@ -107,8 +108,6 @@ function RootComponent() {
// Suppress the app-wide donation toast inside the builder so it doesn't cover the right-sidebar controls.
const isBuilder = useRouterState({ select: (s) => s.location.pathname.startsWith("/builder") });
const iconContextValue = useMemo<IconProps>(() => ({ size: 16, weight: "regular" }), []);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
@@ -1,7 +1,6 @@
import type { TemplateMetadata } from "@/dialogs/resume/template/data";
import { Trans } from "@lingui/react/macro";
import { m } from "motion/react";
import { useMemo } from "react";
import { templates } from "@/dialogs/resume/template/data";
type TemplateItemProps = {
@@ -75,21 +74,12 @@ const createMarqueeItems = (entries: Array<[string, TemplateMetadata]>, rowId: s
{ id: `${rowId}-${template}-repeat`, metadata },
]);
const templateEntries = Object.entries(templates);
const halfway = Math.ceil(templateEntries.length / 2);
const row1 = createMarqueeItems(templateEntries.slice(0, halfway), "row1");
const row2 = createMarqueeItems(templateEntries.slice(halfway), "row2");
export function Templates() {
// Split templates into two rows and duplicate for seamless infinite scroll
const { row1, row2 } = useMemo(() => {
const entries = Object.entries(templates);
const half = Math.ceil(entries.length / 2);
const firstHalf = entries.slice(0, half);
const secondHalf = entries.slice(half);
// Duplicate each row for seamless scrolling
return {
row1: createMarqueeItems(firstHalf, "row1"),
row2: createMarqueeItems(secondHalf, "row2"),
};
}, []);
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<m.div
@@ -1,7 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { QuotesIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { useMemo } from "react";
const email = "hello@amruthpillai.com";
@@ -70,6 +69,11 @@ type TestimonialColumnData = {
testimonials: string[];
};
const testimonialColumns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
testimonialColumns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
type MarqueeMasonryProps = {
columns: TestimonialColumnData[];
direction: "left" | "right";
@@ -97,16 +101,6 @@ function MarqueeMasonry({ columns, direction, duration = 30 }: MarqueeMasonryPro
}
export function Testimonials() {
const columns = useMemo(() => {
const columns: TestimonialColumnData[] = [];
for (let index = 0; index < testimonials.length; index += 2) {
columns.push({ id: `column-${index / 2}`, testimonials: testimonials.slice(index, index + 2) });
}
return columns;
}, []);
return (
<section id="testimonials" className="overflow-hidden py-12 md:py-16 xl:py-20">
<m.div
@@ -145,7 +139,7 @@ export function Testimonials() {
{/* Right fade */}
<div className="pointer-events-none absolute inset-e-0 top-0 bottom-0 z-10 w-16 bg-linear-to-l from-background to-transparent sm:w-24 md:w-32 lg:w-48" />
<MarqueeMasonry columns={columns} direction="left" duration={60} />
<MarqueeMasonry columns={testimonialColumns} direction="left" duration={60} />
</div>
</section>
);
@@ -4,7 +4,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -12,6 +12,7 @@ import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -34,20 +35,12 @@ function isAgentConfigError(error: unknown) {
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const isClient = useIsClient();
const navigate = useNavigate();
const {
data: providers,
isLoading: isLoadingProviders,
error: providersError,
} = useQuery(orpc.aiProviders.list.queryOptions());
const { usableProviders, isLoading: isLoadingProviders, error: providersError } = useHasUsableAiProvider();
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
orpc.resume.list.queryOptions({ input: { sort: "lastUpdatedAt", tags: [] } }),
);
const { mutate: createThread, isPending } = useMutation(orpc.agent.threads.create.mutationOptions());
const usableProviders = useMemo(
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
[providers],
);
const [aiProviderIdOverride, setAiProviderIdOverride] = useState<string | null | undefined>(undefined);
const [sourceResumeIdOverride, setSourceResumeIdOverride] = useState<string | null | undefined>(undefined);
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
@@ -25,6 +25,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
@@ -41,28 +42,6 @@ type AgentThreadSidebarProps = {
className?: string;
};
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 60_000) return formatter.format(0, "second");
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return "";
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
function ThreadActions({ thread, activeThreadId }: ThreadActionsProps) {
const navigate = useNavigate();
const confirm = useConfirm();
@@ -164,7 +143,7 @@ function ThreadRow({ thread, activeThreadId }: ThreadRowProps) {
>
<div className="truncate font-medium">{title}</div>
<div className="truncate text-muted-foreground text-xs">
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter, "")}
</div>
</Link>
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
+1 -1
View File
@@ -3,7 +3,7 @@ import { createNoindexFollowMeta } from "@/libs/seo";
export const Route = createFileRoute("/agent")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { ForgotPasswordPage } from "@/features/auth/pages/forgot-password";
export const Route = createFileRoute("/auth/forgot-password")({
component: ForgotPasswordPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/auth/")({
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
throw redirect({ to: "/auth/login", replace: true });
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { LoginPage } from "@/features/auth/pages/login";
export const Route = createFileRoute("/auth/login")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
return { session: null };
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { RegisterPage } from "@/features/auth/pages/register";
export const Route = createFileRoute("/auth/register")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", replace: true });
return { session: null };
+1 -1
View File
@@ -7,7 +7,7 @@ const searchSchema = z.object({ token: z.string().min(1) });
export const Route = createFileRoute("/auth/reset-password")({
component: RouteComponent,
validateSearch: searchSchema,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.flags.disableEmailAuth) throw redirect({ to: "/auth/login", replace: true });
},
onError: (error) => {
@@ -1,9 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa-backup";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa-backup")({
component: VerifyTwoFactorBackupPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { VerifyTwoFactorPage } from "@/features/auth/pages/verify-2fa";
export const Route = createFileRoute("/auth/verify-2fa")({
component: VerifyTwoFactorPage,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
},
});
@@ -15,7 +15,6 @@ import {
import { useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { useCallback, useMemo } from "react";
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
@@ -72,15 +71,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
redo();
});
const publicUrl = useMemo(() => {
if (!session?.user.username || !resumeSlug) return "";
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
}, [session?.user.username, resumeSlug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const publicUrl =
session?.user.username && resumeSlug ? `${window.location.origin}/${session.user.username}/${resumeSlug}` : "";
return (
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
@@ -111,7 +103,14 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon
icon={LinkSimpleIcon}
title={t`Copy URL`}
onClick={async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}}
/>
</m.div>
</div>
);
@@ -19,29 +19,9 @@ import {
import { useResumeStore } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { orpc } from "@/libs/orpc/client";
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 31_536_000_000, unit: "year" },
{ amount: 2_592_000_000, unit: "month" },
{ amount: 604_800_000, unit: "week" },
{ amount: 86_400_000, unit: "day" },
{ amount: 3_600_000, unit: "hour" },
{ amount: 60_000, unit: "minute" },
];
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
const date = value instanceof Date ? value : new Date(value);
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
// No division matches only when the gap is under a minute (the smallest division), so fall back to seconds.
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
if (!division) return formatter.format(0, "second");
return formatter.format(Math.round(diffMs / division.amount), division.unit);
}
type BuilderVersionHistoryProps = {
resumeId: string;
};
@@ -326,7 +326,6 @@ describe("CustomStylesSectionBuilder", () => {
fireEvent.blur(fontSizeInput);
expect(fontSizeInput).toHaveValue(12);
expect(updateResumeData).toHaveBeenCalledTimes(2);
});
it("commits normalized legacy values when the input loses focus", () => {
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -93,13 +92,8 @@ export function ResumeAnalysisSectionBuilder() {
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
if (score == null) return "bg-muted";
if (score >= 80) return "bg-emerald-600";
if (score >= 60) return "bg-amber-600";
return "bg-rose-600";
}, [score]);
const scoreTone =
score == null ? "bg-muted" : score >= 80 ? "bg-emerald-600" : score >= 60 ? "bg-amber-600" : "bg-rose-600";
const onAnalyze = () => {
if (!resume) return;
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
@@ -29,10 +29,7 @@ export function SharingSectionBuilder() {
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const publicUrl = useMemo(() => {
if (!session) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session, resume]);
const publicUrl = session ? `${window.location.origin}/${session.user.username}/${resume.slug}` : "";
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
@@ -1,6 +1,6 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
@@ -135,17 +135,14 @@ export function useBuilderSidebar(): UseBuilderSidebarReturn {
[expandSize],
);
// ponytail: memoized but callers destructure; selector removed (state rebuilt every render, zero benefit)
return useMemo(() => {
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}, [maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior, isCollapsed, toggleSidebar]);
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}
export const setBuilderLayout = (data: BuilderLayout) => {
@@ -12,7 +12,7 @@ import { getBuilderLayout } from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
@@ -19,10 +16,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = {
};
export function ResumeContextMenu({ resume, children }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<ContextMenu>
@@ -1,5 +1,4 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
@@ -9,9 +8,7 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,10 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
resume: RouterOutput["resume"]["list"][number];
@@ -30,60 +24,7 @@ type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children">
};
export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", resume);
};
const handleDuplicate = () => {
openDialog("resume.duplicate", resume);
};
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<DropdownMenu>
@@ -0,0 +1,54 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type Resume = RouterOutput["resume"]["list"][number];
export function useResumeMenuActions(resume: Resume) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmed = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmed) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{ onError: (error) => toast.error(getResumeErrorMessage(error)) },
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmed) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id: resume.id },
{
onSuccess: () => toast.success(t`Your resume has been deleted successfully.`, { id: toastId }),
onError: (error) => toast.error(getResumeErrorMessage(error), { id: toastId }),
},
);
};
return {
handleDelete,
handleDuplicate: () => openDialog("resume.duplicate", resume),
handleToggleLock,
handleUpdate: () => openDialog("resume.update", resume),
};
}
+2 -2
View File
@@ -7,11 +7,11 @@ import { DashboardSidebar } from "./-components/sidebar";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async () => {
loader: () => {
const sidebarState = getDashboardSidebarState();
return { sidebarState };
},