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 { Icon as IconType } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { SidebarTrigger } from "@reactive-resume/ui/components/sidebar";
import { cn } from "@reactive-resume/utils/style";
@@ -6,14 +7,18 @@ type Props = {
title: string;
icon: IconType;
className?: string;
actions?: ReactNode;
};
export function DashboardHeader({ title, icon: IconComponent, className }: Props) {
export function DashboardHeader({ title, icon: IconComponent, className, actions }: Props) {
return (
<div className={cn("relative flex items-center justify-center gap-x-2.5 md:justify-start", className)}>
<div className={cn("relative flex items-center gap-x-2.5", className)}>
<SidebarTrigger className="absolute inset-s-0 md:hidden" />
<IconComponent weight="light" className="size-5" />
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
<div className="flex flex-1 items-center justify-center gap-x-2.5 md:justify-start">
<IconComponent weight="light" className="size-5" />
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
</div>
{actions ? <div className="flex items-center gap-x-2">{actions}</div> : null}
</div>
);
}
@@ -7,6 +7,7 @@ import {
ChatCircleDotsIcon,
GearSixIcon,
KeyIcon,
MagnifyingGlassIcon,
ReadCvLogoIcon,
ShieldCheckIcon,
UserCircleIcon,
@@ -16,6 +17,7 @@ import { Link } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import {
Sidebar,
SidebarContent,
@@ -33,6 +35,7 @@ import {
} from "@reactive-resume/ui/components/sidebar";
import { getInitials } from "@reactive-resume/utils/string";
import { Copyright } from "@/components/ui/copyright";
import { useCommandPaletteStore } from "@/features/command-palette/store";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
type SidebarItem = {
@@ -115,7 +118,27 @@ function SidebarItemList({ items }: SidebarItemListProps) {
);
}
function SidebarSearchButton() {
const { i18n } = useLingui();
const setOpen = useCommandPaletteStore((state) => state.setOpen);
const label = i18n.t(msg`Search`);
return (
<SidebarMenuItem>
<SidebarMenuButton title={label} tooltip={label} onClick={() => setOpen(true)}>
<MagnifyingGlassIcon />
<span className="flex-1 text-start transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
{label}
</span>
<Kbd className="transition-opacity duration-200 ease-in-out group-data-[collapsible=icon]:opacity-0">K</Kbd>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export function DashboardSidebar() {
const { i18n } = useLingui();
const { state } = useSidebarState();
return (
@@ -133,12 +156,14 @@ export function DashboardSidebar() {
}
/>
</SidebarMenuItem>
<SidebarSearchButton />
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarContent aria-label={i18n.t(msg`Dashboard`)} role="navigation">
<SidebarGroup>
<SidebarGroupLabel>
<Trans>App</Trans>
@@ -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>
);
}
+9 -1
View File
@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
import { SidebarProvider } from "@reactive-resume/ui/components/sidebar";
import { createNoindexFollowMeta } from "@/libs/seo";
@@ -30,9 +31,16 @@ function RouteComponent() {
return (
<SidebarProvider open={sidebarState} onOpenChange={handleSidebarOpenChange}>
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<DashboardSidebar />
<main className="@container flex-1 p-4 md:ps-2">
<main id="main-content" className="@container flex-1 p-4 md:ps-2">
<Outlet />
</main>
</SidebarProvider>