* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
@@ -0,0 +1,40 @@
import { Badge } from "@reactive-resume/ui/components/badge";
import { cn } from "@reactive-resume/utils/style";
import { CometCard } from "@/components/animation/comet-card";
type BaseCardProps = React.ComponentProps<"div"> & {
title: string;
description: string;
tags?: string[];
className?: string;
children?: React.ReactNode;
};
export function BaseCard({ title, description, tags, className, children, ...props }: BaseCardProps) {
return (
<CometCard translateDepth={3} rotateDepth={6}>
<div
{...props}
className={cn(
"relative flex aspect-page size-full overflow-hidden rounded-md bg-popover shadow transition-shadow hover:shadow-xl",
className,
)}
>
{children}
<div className="absolute inset-x-0 bottom-0 flex w-full flex-col justify-end space-y-0.5 bg-background/40 px-4 py-3 backdrop-blur-xs">
<h3 className="truncate font-medium tracking-tight">{title}</h3>
<p className="truncate text-xs opacity-80">{description}</p>
<div className={cn("mt-2 hidden flex-wrap items-center gap-1", tags && tags.length > 0 && "flex")}>
{tags?.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
</div>
</CometCard>
);
}
@@ -0,0 +1,20 @@
import { t } from "@lingui/core/macro";
import { PlusIcon } from "@phosphor-icons/react";
import { useDialogStore } from "@/dialogs/store";
import { BaseCard } from "./base-card";
export function CreateResumeCard() {
const { openDialog } = useDialogStore();
return (
<BaseCard
title={t`Create a new resume`}
description={t`Start building your resume from scratch`}
onClick={() => openDialog("resume.create", undefined)}
>
<div className="absolute inset-0 flex items-center justify-center">
<PlusIcon weight="thin" className="size-12" />
</div>
</BaseCard>
);
}
@@ -0,0 +1,20 @@
import { t } from "@lingui/core/macro";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { useDialogStore } from "@/dialogs/store";
import { BaseCard } from "./base-card";
export function ImportResumeCard() {
const { openDialog } = useDialogStore();
return (
<BaseCard
title={t`Import an existing resume`}
description={t`Continue where you left off`}
onClick={() => openDialog("resume.import", undefined)}
>
<div className="absolute inset-0 flex items-center justify-center">
<DownloadSimpleIcon weight="thin" className="size-12" />
</div>
</BaseCard>
);
}
@@ -0,0 +1,62 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { ResumeContextMenu } from "../menus/context-menu";
import { BaseCard } from "./base-card";
import { ResumeThumbnail } from "./resume-thumbnail";
type ResumeCardProps = {
resume: RouterOutput["resume"]["list"][number];
};
export function ResumeCard({ resume }: ResumeCardProps) {
const { i18n } = useLingui();
const updatedAt = useMemo(() => {
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
}, [i18n.locale, resume.updatedAt]);
return (
<ResumeContextMenu resume={resume}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
<motion.div
className="will-change-transform"
whileHover={{ y: -2, scale: 1.005 }}
whileTap={{ scale: 0.998 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
>
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
<ResumeThumbnail resume={resume} isLocked={resume.isLocked} />
<ResumeLockOverlay isLocked={resume.isLocked} />
</BaseCard>
</motion.div>
</Link>
</ResumeContextMenu>
);
}
function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
return (
<AnimatePresence>
{isLocked && (
<motion.div
key="resume-lock-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 flex items-center justify-center will-change-[opacity]"
>
<div className="flex items-center justify-center rounded-full bg-popover p-6">
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,26 @@
type PageSize = {
height: number;
width: number;
};
export const RESUME_THUMBNAIL_TARGET_WIDTH = 420;
const MAX_THUMBNAIL_PIXEL_RATIO = 2;
export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => {
return `${resumeId}:${updatedAt.getTime()}`;
};
export const getResumeThumbnailRenderSize = (
pageSize: PageSize,
targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH,
pixelRatio = 1,
) => {
const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO);
const pageScale = targetWidth / pageSize.width;
return {
height: Math.round(pageSize.height * pageScale * outputScale),
scale: pageScale * outputScale,
width: Math.round(targetWidth * outputScale),
};
};
@@ -0,0 +1,189 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { RouterOutput } from "@/libs/orpc/client";
import { FileTextIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useInView } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import {
getResumeThumbnailCacheKey,
getResumeThumbnailRenderSize,
RESUME_THUMBNAIL_TARGET_WIDTH,
} from "./resume-thumbnail.shared";
type ResumeListItem = RouterOutput["resume"]["list"][number];
type ThumbnailState = { status: "error" | "idle" | "loading" } | { status: "ready"; url: string };
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to create resume thumbnail image."));
return;
}
resolve(blob);
}, "image/png");
});
};
const createPdfFirstPageImageUrl = async (file: Blob) => {
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist");
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
const arrayBuffer = await file.arrayBuffer();
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
try {
pdfDocument = await loadingTask.promise;
const page = await pdfDocument.getPage(1);
try {
const baseViewport = page.getViewport({ scale: 1 });
const renderSize = getResumeThumbnailRenderSize(
{ height: baseViewport.height, width: baseViewport.width },
RESUME_THUMBNAIL_TARGET_WIDTH,
window.devicePixelRatio || 1,
);
const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d");
if (!canvasContext) throw new Error("Failed to create resume thumbnail canvas context.");
canvas.height = renderSize.height;
canvas.width = renderSize.width;
const viewport = page.getViewport({ scale: renderSize.scale });
const renderTask = page.render({
canvas,
canvasContext,
viewport,
annotationMode: AnnotationMode.DISABLE,
background: "white",
});
await renderTask.promise;
const image = await canvasToBlob(canvas);
return URL.createObjectURL(image);
} finally {
page.cleanup();
}
} finally {
if (pdfDocument) {
void pdfDocument.destroy();
} else {
void loadingTask.destroy();
}
}
};
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined) {
const [thumbnail, setThumbnail] = useState<ThumbnailState>({ status: "idle" });
const currentUrlRef = useRef<string | null>(null);
const revokeCurrentThumbnail = useCallback(() => {
if (!currentUrlRef.current) return;
URL.revokeObjectURL(currentUrlRef.current);
currentUrlRef.current = null;
}, []);
useEffect(() => {
return () => {
revokeCurrentThumbnail();
};
}, [revokeCurrentThumbnail]);
useEffect(() => {
if (!data || !cacheKey) {
revokeCurrentThumbnail();
setThumbnail({ status: "idle" });
return;
}
let isCancelled = false;
let nextUrl: string | null = null;
setThumbnail({ status: "loading" });
const generateThumbnail = async () => {
try {
const pdf = await createResumePdfBlob(data);
if (isCancelled) return;
nextUrl = await createPdfFirstPageImageUrl(pdf);
if (isCancelled) {
URL.revokeObjectURL(nextUrl);
nextUrl = null;
return;
}
revokeCurrentThumbnail();
currentUrlRef.current = nextUrl;
setThumbnail({ status: "ready", url: nextUrl });
nextUrl = null;
} catch (error) {
if (isCancelled) return;
console.error("Failed to generate resume thumbnail", error);
revokeCurrentThumbnail();
setThumbnail({ status: "error" });
}
};
void generateThumbnail();
return () => {
isCancelled = true;
if (nextUrl) {
URL.revokeObjectURL(nextUrl);
}
};
}, [data, cacheKey, revokeCurrentThumbnail]);
return thumbnail;
}
export function ResumeThumbnail({ isLocked, resume }: { isLocked: boolean; resume: ResumeListItem }) {
const containerRef = useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
const resumeQuery = useQuery({
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
enabled: isInView,
});
const thumbnail = useResumeThumbnail(
resumeQuery.data?.data,
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
);
const hasFailed = resumeQuery.isError || thumbnail.status === "error";
return (
<div
ref={containerRef}
className={cn("relative size-full overflow-hidden bg-muted/40 transition-all", isLocked && "blur-xs")}
>
{thumbnail.status === "ready" ? (
<div
aria-hidden
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat"
style={{ backgroundImage: `url(${thumbnail.url})` }}
/>
) : hasFailed ? (
<div className="absolute inset-0 flex items-center justify-center">
<FileTextIcon weight="thin" className="size-12 opacity-40" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { AnimatePresence, motion } from "motion/react";
import { CreateResumeCard } from "./cards/create-card";
import { ImportResumeCard } from "./cards/import-card";
import { ResumeCard } from "./cards/resume-card";
type Resume = RouterOutput["resume"]["list"][number];
type Props = {
resumes: Resume[];
};
export function GridView({ resumes }: Props) {
return (
<div className="grid 3xl:grid-cols-6 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<CreateResumeCard />
</motion.div>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<ImportResumeCard />
</motion.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
<motion.div
layout
key={resume.id}
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: -20,
filter: "blur(8px)",
}}
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<ResumeCard resume={resume} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,132 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { DotsThreeIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { ResumeDropdownMenu } from "./menus/dropdown-menu";
type Resume = RouterOutput["resume"]["list"][number];
type Props = {
resumes: Resume[];
};
export function ListView({ resumes }: Props) {
const { openDialog } = useDialogStore();
const handleCreateResume = () => {
openDialog("resume.create", undefined);
};
const handleImportResume = () => {
openDialog("resume.import", undefined);
};
return (
<div className="flex flex-col gap-y-1">
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Button
size="lg"
variant="ghost"
className="h-12 w-full justify-start gap-x-4 text-start"
onClick={handleCreateResume}
>
<PlusIcon />
<div className="min-w-80 truncate">
<Trans>Create a new resume</Trans>
</div>
<p className="text-xs opacity-60">
<Trans>Start building your resume from scratch</Trans>
</p>
</Button>
</motion.div>
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
>
<Button
size="lg"
variant="ghost"
className="h-12 w-full justify-start gap-x-4 text-start"
onClick={handleImportResume}
>
<DownloadSimpleIcon />
<div className="min-w-80 truncate">
<Trans>Import an existing resume</Trans>
</div>
<p className="text-xs opacity-60">
<Trans>Continue where you left off</Trans>
</p>
</Button>
</motion.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
<motion.div
layout
key={resume.id}
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
>
<ResumeListItem resume={resume} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}
function ResumeListItem({ resume }: { resume: Resume }) {
const { i18n } = useLingui();
const updatedAt = useMemo(() => {
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
}, [i18n.locale, resume.updatedAt]);
return (
<div className="flex items-center gap-x-2">
<Button
size="lg"
variant="ghost"
nativeButton={false}
className="h-12 w-full flex-1 justify-start gap-x-4 text-start"
render={
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<div className="size-3" />
<div className="min-w-80 truncate">{resume.name}</div>
<p className="text-xs opacity-60">
<Trans>Last updated on {updatedAt}</Trans>
</p>
</Link>
}
/>
<ResumeDropdownMenu resume={resume} align="end">
<Button size="icon" variant="ghost" className="size-12">
<DotsThreeIcon />
</Button>
</ResumeDropdownMenu>
</div>
);
}
@@ -0,0 +1,132 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
FolderOpenIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
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,
ContextMenuItem,
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";
type Props = {
resume: RouterOutput["resume"]["list"][number];
children: React.ComponentProps<typeof ContextMenuTrigger>["render"];
};
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 });
},
},
);
};
return (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<ContextMenuItem
render={
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<FolderOpenIcon />
<Trans comment="Resume card context menu action to open the resume editor">Open</Trans>
</Link>
}
/>
<ContextMenuSeparator />
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
</ContextMenuItem>
<ContextMenuItem onClick={handleDuplicate}>
<CopySimpleIcon />
<Trans comment="Resume card context menu action to create a copy">Duplicate</Trans>
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleLock}>
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
{resume.isLocked ? (
<Trans comment="Resume card context menu action to remove edit lock">Unlock</Trans>
) : (
<Trans comment="Resume card context menu action to prevent edits">Lock</Trans>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
<TrashSimpleIcon />
<Trans comment="Resume card context menu destructive action to remove a resume">Delete</Trans>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,130 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
FolderOpenIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
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,
DropdownMenuItem,
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";
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
resume: RouterOutput["resume"]["list"][number];
children: React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
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 });
},
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger render={children} />
<DropdownMenuContent {...props}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<DropdownMenuItem>
<FolderOpenIcon />
<Trans comment="Resume card dropdown action to open the resume editor">Open</Trans>
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDuplicate}>
<CopySimpleIcon />
<Trans comment="Resume card dropdown action to create a copy">Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleLock}>
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
{resume.isLocked ? (
<Trans comment="Resume card dropdown action to remove edit lock">Unlock</Trans>
) : (
<Trans comment="Resume card dropdown action to prevent edits">Lock</Trans>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
<TrashSimpleIcon />
<Trans comment="Resume card dropdown destructive action to remove a resume">Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,122 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { GridFourIcon, ListIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { useMemo } from "react";
import z from "zod";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { orpc } from "@/libs/orpc/client";
import { DashboardHeader } from "../-components/header";
import { GridView } from "./-components/grid-view";
import { ListView } from "./-components/list-view";
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
const searchSchema = z.object({
tags: z.array(z.string()).default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
view: z.enum(["grid", "list"]).default("grid"),
});
export const Route = createFileRoute("/dashboard/resumes/")({
component: RouteComponent,
validateSearch: zodValidator(searchSchema),
search: {
middlewares: [stripSearchParams({ tags: [], sort: "lastUpdatedAt", view: "grid" })],
},
});
function RouteComponent() {
const { i18n } = useLingui();
const { tags, sort, view } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { data: allTags } = useQuery(orpc.resume.tags.list.queryOptions());
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: { tags, sort } }));
const tagOptions = useMemo(() => {
if (!allTags) return [];
return allTags.map((tag) => ({ value: tag, label: tag }));
}, [allTags]);
const sortOptions = useMemo(() => {
return [
{ value: "lastUpdatedAt", label: i18n.t("Last Updated") },
{ value: "createdAt", label: i18n.t("Created") },
{ value: "name", label: i18n.t("Name") },
];
}, [i18n]);
return (
<div className="space-y-4">
<DashboardHeader icon={ReadCvLogoIcon} title={t`Resumes`} />
<Separator />
<div className="flex items-center gap-x-4">
<div className="flex gap-2">
<Label>
<Trans>Sort by</Trans>
</Label>
<Combobox
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: { tags, sort: value as SortOption } });
}}
/>
</div>
<div className={cn("flex gap-2", { hidden: tagOptions.length === 0 })}>
<Label>
<Trans>Filter by</Trans>
</Label>
<Combobox
multiple
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
onValueChange={(value) => {
void navigate({ search: { tags: value ?? [], sort } });
}}
/>
</div>
<Tabs className="ltr:ms-auto rtl:me-auto" value={view}>
<TabsList>
<TabsTrigger
value="grid"
nativeButton={false}
className="rounded-r-none"
render={<Link to="." search={{ view: "grid" }} />}
>
<GridFourIcon />
<Trans>Grid</Trans>
</TabsTrigger>
<TabsTrigger
value="list"
nativeButton={false}
className="rounded-l-none"
render={<Link to="." search={{ view: "list" }} />}
>
<ListIcon />
<Trans>List</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{view === "list" ? <ListView resumes={resumes ?? []} /> : <GridView resumes={resumes ?? []} />}
</div>
);
}