mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user