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