v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
@@ -1,4 +1,5 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, m } from "motion/react";
import { CreateResumeCard } from "./cards/create-card";
import { ImportResumeCard } from "./cards/import-card";
@@ -8,44 +9,59 @@ type Resume = RouterOutput["resume"]["list"][number];
type Props = {
resumes: Resume[];
hasResumes: boolean;
};
export function GridView({ resumes }: Props) {
export function GridView({ resumes, hasResumes }: Props) {
if (resumes.length === 0 && hasResumes) {
return (
<p className="py-8 text-center text-muted-foreground text-sm">
<Trans>No resumes match your search.</Trans>
</p>
);
}
if (resumes.length === 0) {
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">
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<CreateResumeCard />
</m.div>
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<ImportResumeCard />
</m.div>
</div>
);
}
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">
<m.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 />
</m.div>
<m.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 />
</m.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
{resumes.map((resume, index) => (
<m.div
layout
key={resume.id}
initial={{ opacity: 0, y: -20 }}
initial={{ 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" }}
transition={{ duration: 0.2, delay: Math.min(0.12, index * 0.02), ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<ResumeCard resume={resume} />
@@ -13,76 +13,91 @@ type Resume = RouterOutput["resume"]["list"][number];
type ListViewProps = {
resumes: Resume[];
hasResumes: boolean;
};
type ResumeListItemProps = {
resume: Resume;
};
export function ListView({ resumes }: ListViewProps) {
export function ListView({ resumes, hasResumes }: ListViewProps) {
const { openDialog } = useDialogStore();
const handleCreateResume = () => {
openDialog("resume.create", undefined);
};
if (resumes.length === 0 && hasResumes) {
return (
<p className="py-8 text-center text-muted-foreground text-sm">
<Trans>No resumes match your search.</Trans>
</p>
);
}
const handleImportResume = () => {
openDialog("resume.import", undefined);
};
if (resumes.length === 0) {
const handleCreateResume = () => {
openDialog("resume.create", undefined);
};
const handleImportResume = () => {
openDialog("resume.import", undefined);
};
return (
<div className="flex flex-col gap-y-1">
<m.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>
</m.div>
<m.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>
</m.div>
</div>
);
}
return (
<div className="flex flex-col gap-y-1">
<m.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>
</m.div>
<m.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>
</m.div>
<AnimatePresence initial={false} mode="popLayout">
{resumes?.map((resume, index) => (
{resumes.map((resume, index) => (
<m.div
layout
key={resume.id}
@@ -90,7 +105,7 @@ export function ListView({ resumes }: ListViewProps) {
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" }}
transition={{ duration: 0.18, delay: Math.min(0.12, index * 0.02), ease: "easeOut" }}
>
<ResumeListItem resume={resume} />
</m.div>
@@ -103,7 +103,7 @@ export function ResumeContextMenu({ resume, children }: Props) {
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card context menu action to edit resume metadata">Update</Trans>
<Trans comment="Resume card context menu action to edit resume metadata">Edit details</Trans>
</ContextMenuItem>
<ContextMenuItem onClick={handleDuplicate}>
@@ -101,7 +101,7 @@ export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card dropdown action to edit resume metadata">Update</Trans>
<Trans comment="Resume card dropdown action to edit resume metadata">Edit details</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDuplicate}>
@@ -1,16 +1,26 @@
import { t } from "@lingui/core/macro";
import { msg, 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 {
DownloadSimpleIcon,
GridFourIcon,
ListIcon,
MagnifyingGlassIcon,
PlusIcon,
ReadCvLogoIcon,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { useMemo } from "react";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
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 { useDialogStore } from "@/dialogs/store";
import { orpc } from "@/libs/orpc/client";
import { DashboardHeader } from "../-components/header";
import { GridView } from "./-components/grid-view";
@@ -19,6 +29,7 @@ import { ListView } from "./-components/list-view";
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
const searchSchema = z.object({
search: z.string().default(""),
tags: z.array(z.string()).default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
view: z.enum(["grid", "list"]).default("grid"),
@@ -26,7 +37,7 @@ const searchSchema = z.object({
type Search = z.output<typeof searchSchema>;
const defaultSearch: Search = { tags: [], sort: "lastUpdatedAt", view: "grid" };
const defaultSearch: Search = { search: "", tags: [], sort: "lastUpdatedAt", view: "grid" };
export const Route = createFileRoute("/dashboard/resumes/")({
component: RouteComponent,
@@ -38,12 +49,22 @@ export const Route = createFileRoute("/dashboard/resumes/")({
function RouteComponent() {
const { i18n } = useLingui();
const { tags, sort, view } = Route.useSearch();
const { search, tags, sort, view } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { openDialog } = useDialogStore();
const { data: allTags } = useQuery(orpc.resume.tags.list.queryOptions());
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: { tags, sort } }));
const filteredResumes = useMemo(() => {
const list = resumes ?? [];
const query = search.trim().toLowerCase();
if (!query) return list;
return list.filter(
(resume) => resume.name.toLowerCase().includes(query) || resume.slug.toLowerCase().includes(query),
);
}, [resumes, search]);
const tagOptions = useMemo(() => {
if (!allTags) return [];
return allTags.map((tag) => ({ value: tag, label: tag }));
@@ -51,15 +72,32 @@ function RouteComponent() {
const sortOptions = useMemo(() => {
return [
{ value: "lastUpdatedAt", label: i18n.t("Last Updated") },
{ value: "createdAt", label: i18n.t("Created") },
{ value: "name", label: i18n.t("Name") },
{ value: "lastUpdatedAt", label: i18n.t(msg`Last Updated`) },
{ value: "createdAt", label: i18n.t(msg`Created`) },
{ value: "name", label: i18n.t(msg`Name`) },
];
}, [i18n]);
return (
<div className="space-y-4">
<DashboardHeader icon={ReadCvLogoIcon} title={t`Resumes`} />
<DashboardHeader
icon={ReadCvLogoIcon}
title={t`Resumes`}
actions={
(resumes?.length ?? 0) > 0 ? (
<>
<Button size="sm" variant="outline" onClick={() => openDialog("resume.create", undefined)}>
<PlusIcon />
<Trans>Create</Trans>
</Button>
<Button size="sm" variant="outline" onClick={() => openDialog("resume.import", undefined)}>
<DownloadSimpleIcon />
<Trans>Import</Trans>
</Button>
</>
) : undefined
}
/>
<Separator />
@@ -94,6 +132,22 @@ function RouteComponent() {
/>
</div>
{(resumes?.length ?? 0) > 5 && (
<InputGroup className="w-56">
<InputGroupAddon align="inline-start">
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupInput
value={search}
placeholder={t`Search resumes...`}
onChange={(event) => {
const value = event.target.value;
void navigate({ search: (prev: Search) => ({ ...prev, search: value }) });
}}
/>
</InputGroup>
)}
<Tabs className="ltr:ms-auto rtl:me-auto" value={view}>
<TabsList>
<TabsTrigger
@@ -119,7 +173,11 @@ function RouteComponent() {
</Tabs>
</div>
{view === "list" ? <ListView resumes={resumes ?? []} /> : <GridView resumes={resumes ?? []} />}
{view === "list" ? (
<ListView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} />
) : (
<GridView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} />
)}
</div>
);
}