mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowUUpLeftIcon,
|
||||
ArrowUUpRightIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useHotkeys } from "@tanstack/react-hotkeys";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useControls } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useResumeHistory } from "@/components/resume/use-resume";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
|
||||
export function BuilderDock() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { zoomIn, zoomOut, centerView } = useControls();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const { undo, redo, canUndo, canRedo } = useResumeHistory();
|
||||
|
||||
useHotkeys([
|
||||
{ hotkey: "Mod+Z", callback: () => undo() },
|
||||
{ hotkey: "Mod+Y", callback: () => redo() },
|
||||
]);
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session?.user.username, resume?.slug]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
|
||||
>
|
||||
<DockIcon
|
||||
disabled={!canUndo}
|
||||
onClick={() => undo()}
|
||||
icon={ArrowUUpLeftIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Undo (Ctrl+Z)",
|
||||
})}
|
||||
/>
|
||||
<DockIcon
|
||||
disabled={!canRedo}
|
||||
onClick={() => redo()}
|
||||
icon={ArrowUUpRightIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Redo (Ctrl+Y)",
|
||||
})}
|
||||
/>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.1)} />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
|
||||
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
onClick={() => onDownloadPDF()}
|
||||
icon={isPrinting ? CircleNotchIcon : FilePdfIcon}
|
||||
iconClassName={cn(isPrinting && "animate-spin")}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DockIconProps = {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TooltipContent side="top" align="center" className="font-medium">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type Props = {
|
||||
side: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BuilderSidebarEdge({ side, children }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 hidden min-h-0 w-12 flex-col items-center overflow-hidden bg-popover py-2.5 sm:flex",
|
||||
side === "left" ? "inset-s-0 border-r" : "inset-e-0 border-l",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CopySimpleIcon,
|
||||
HouseSimpleIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
SidebarSimpleIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const resume = useCurrentResume();
|
||||
const name = resume.name;
|
||||
const isLocked = resume.isLocked;
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-0 z-50 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
|
||||
Toggle left sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for button navigating from builder to resumes dashboard",
|
||||
message: "Go to resumes dashboard",
|
||||
})}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
<HouseSimpleIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<span className="me-2.5 text-muted-foreground">/</span>
|
||||
<h2 className="flex-1 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
|
||||
<BuilderHeaderDropdown />
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
|
||||
Toggle right sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderHeaderDropdown() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
const id = resume.id;
|
||||
const name = resume.name;
|
||||
const slug = resume.slug;
|
||||
const tags = resume.tags;
|
||||
const isLocked = resume.isLocked;
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
const handleUpdate = () => {
|
||||
openDialog("resume.update", { id, name, slug, tags });
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true });
|
||||
};
|
||||
|
||||
const handleToggleLock = async () => {
|
||||
if (!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, isLocked: !isLocked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
patchResume((draft) => {
|
||||
draft.isLocked = !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 },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon className="me-2" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon className="me-2" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
|
||||
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon className="me-2" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { Suspense } from "react";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { BuilderDock } from "./dock";
|
||||
|
||||
export function PreviewPage() {
|
||||
useHotkey("Mod+S", () => {
|
||||
toast.info(t`Your changes are saved automatically.`, { id: "auto-save", icon: <FloppyDiskIcon /> });
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<div className="fixed inset-0">
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
maxScale={6}
|
||||
minScale={0.3}
|
||||
initialScale={0.6}
|
||||
limitToBounds={false}
|
||||
wheel={{ step: 0.001 }}
|
||||
>
|
||||
<TransformComponent wrapperClass="h-full! w-full!">
|
||||
<ResumePreview pageGap="2rem" showPageNumbers />
|
||||
</TransformComponent>
|
||||
|
||||
<BuilderDock />
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
|
||||
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { AwardsSectionBuilder } from "./sections/awards";
|
||||
import { BasicsSectionBuilder } from "./sections/basics";
|
||||
import { CertificationsSectionBuilder } from "./sections/certifications";
|
||||
import { CustomSectionBuilder } from "./sections/custom";
|
||||
import { EducationSectionBuilder } from "./sections/education";
|
||||
import { ExperienceSectionBuilder } from "./sections/experience";
|
||||
import { InterestsSectionBuilder } from "./sections/interests";
|
||||
import { LanguagesSectionBuilder } from "./sections/languages";
|
||||
import { PictureSectionBuilder } from "./sections/picture";
|
||||
import { ProfilesSectionBuilder } from "./sections/profiles";
|
||||
import { ProjectsSectionBuilder } from "./sections/projects";
|
||||
import { PublicationsSectionBuilder } from "./sections/publications";
|
||||
import { ReferencesSectionBuilder } from "./sections/references";
|
||||
import { SkillsSectionBuilder } from "./sections/skills";
|
||||
import { SummarySectionBuilder } from "./sections/summary";
|
||||
import { VolunteerSectionBuilder } from "./sections/volunteer";
|
||||
|
||||
function getSectionComponent(type: LeftSidebarSection) {
|
||||
return match(type)
|
||||
.with("picture", () => <PictureSectionBuilder />)
|
||||
.with("basics", () => <BasicsSectionBuilder />)
|
||||
.with("summary", () => <SummarySectionBuilder />)
|
||||
.with("profiles", () => <ProfilesSectionBuilder />)
|
||||
.with("experience", () => <ExperienceSectionBuilder />)
|
||||
.with("education", () => <EducationSectionBuilder />)
|
||||
.with("projects", () => <ProjectsSectionBuilder />)
|
||||
.with("skills", () => <SkillsSectionBuilder />)
|
||||
.with("languages", () => <LanguagesSectionBuilder />)
|
||||
.with("interests", () => <InterestsSectionBuilder />)
|
||||
.with("awards", () => <AwardsSectionBuilder />)
|
||||
.with("certifications", () => <CertificationsSectionBuilder />)
|
||||
.with("publications", () => <PublicationsSectionBuilder />)
|
||||
.with("volunteer", () => <VolunteerSectionBuilder />)
|
||||
.with("references", () => <ReferencesSectionBuilder />)
|
||||
.with("custom", () => <CustomSectionBuilder />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function BuilderSidebarLeft() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
|
||||
<div className="space-y-4 p-4">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: LeftSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("left", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<BuilderSidebarEdge side="left">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center gap-y-2 overflow-hidden">
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserDropdownMenu>
|
||||
{({ session }) => (
|
||||
<Button size="icon" variant="ghost">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={session.user.image ?? undefined} />
|
||||
<AvatarFallback className="text-[0.5rem]">{getInitials(session.user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
)}
|
||||
</UserDropdownMenu>
|
||||
</div>
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { awardItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.awards;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.awards.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="awards" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="awards" item={item} title={item.title} subtitle={item.awarder} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="awards">
|
||||
<Trans>Add a new award</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { basicsSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { URLInput } from "@/components/input/url-input";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { CustomFieldsSection } from "./custom-fields";
|
||||
|
||||
export function BasicsSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="basics">
|
||||
<BasicsSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = basicsSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function BasicsSectionForm() {
|
||||
const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.basics = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: basics,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="headline">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Headline</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
type="email"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="phone">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Phone</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="location">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="website">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<CustomFieldsSection form={form} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { certificationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.certifications;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.certifications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase
|
||||
type="certifications"
|
||||
className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}
|
||||
>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="certifications"
|
||||
item={item}
|
||||
title={item.title}
|
||||
subtitle={[item.issuer, item.date].filter(Boolean).join(" • ") || undefined}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="certifications">
|
||||
<Trans>Add a new certification</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { basicsSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { FormControl, FormItem } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { withForm } from "@/libs/tanstack-form";
|
||||
|
||||
type FormValues = z.infer<typeof basicsSchema>;
|
||||
type CustomField = FormValues["customFields"][number];
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
name: "",
|
||||
headline: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
location: "",
|
||||
website: { url: "", label: "" },
|
||||
customFields: [],
|
||||
};
|
||||
|
||||
export const CustomFieldsSection = withForm({
|
||||
defaultValues,
|
||||
render: ({ form }) => {
|
||||
return (
|
||||
<form.Field name="customFields" mode="array">
|
||||
{(customFieldsField) => (
|
||||
<Reorder.Group
|
||||
className="touch-none space-y-4"
|
||||
values={customFieldsField.state.value}
|
||||
onReorder={(fields) => {
|
||||
customFieldsField.setValue(fields);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
{customFieldsField.state.value.map((field: CustomField, index: number) => (
|
||||
<CustomFieldItem key={field.id} field={field}>
|
||||
<form.Field name={`customFields[${index}].icon`}>
|
||||
{(iconField) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker
|
||||
name={iconField.name}
|
||||
value={iconField.state.value}
|
||||
className="rounded-r-none! border-e-0!"
|
||||
onChange={(icon) => {
|
||||
iconField.handleChange(icon);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name={`customFields[${index}].text`}>
|
||||
{(textField) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={textField.name}
|
||||
value={textField.state.value}
|
||||
className="rounded-l-none!"
|
||||
onChange={(e) => {
|
||||
textField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name={`customFields[${index}].link`}>
|
||||
{(linkField) => (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" className="ms-1">
|
||||
<LinkIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent align="center">
|
||||
<div className="flex flex-col gap-y-1.5">
|
||||
<Label htmlFor={linkField.name} className="text-muted-foreground text-xs">
|
||||
<Trans>Enter the URL to link to</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="url"
|
||||
value={linkField.state.value}
|
||||
id={linkField.name}
|
||||
placeholder={t({
|
||||
comment: "Placeholder text for custom link URL field in resume builder",
|
||||
message: "Must start with https://",
|
||||
})}
|
||||
onChange={(e) => {
|
||||
linkField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.removeValue(index);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</CustomFieldItem>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ListPlusIcon />
|
||||
<Trans>Add a custom field</Trans>
|
||||
</Button>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
type CustomFieldItemProps = {
|
||||
field: CustomField;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function CustomFieldItem({ field, children }: CustomFieldItemProps) {
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={field.id}
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex touch-none items-center"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="me-2 touch-none"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
CustomSection,
|
||||
CustomSectionItem as CustomSectionItemType,
|
||||
CustomSectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ColumnsIcon,
|
||||
CopySimpleIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
PencilSimpleLineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { stripHtml } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string {
|
||||
return match(type)
|
||||
.with("summary", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is empty",
|
||||
message: "Summary",
|
||||
});
|
||||
}
|
||||
return t({
|
||||
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
|
||||
message: "Summary",
|
||||
});
|
||||
})
|
||||
.with("profiles", () => ("network" in item ? item.network : ""))
|
||||
.with("experience", () => ("company" in item ? item.company : ""))
|
||||
.with("education", () => ("school" in item ? item.school : ""))
|
||||
.with("projects", () => ("name" in item ? item.name : ""))
|
||||
.with("skills", () => ("name" in item ? item.name : ""))
|
||||
.with("languages", () => ("language" in item ? item.language : ""))
|
||||
.with("interests", () => ("name" in item ? item.name : ""))
|
||||
.with("awards", () => ("title" in item ? item.title : ""))
|
||||
.with("certifications", () => ("title" in item ? item.title : ""))
|
||||
.with("publications", () => ("title" in item ? item.title : ""))
|
||||
.with("volunteer", () => ("organization" in item ? item.organization : ""))
|
||||
.with("references", () => ("name" in item ? item.name : ""))
|
||||
.with("cover-letter", () => {
|
||||
if ("recipient" in item) {
|
||||
const stripped = stripHtml(item.recipient);
|
||||
return stripped.length > 50
|
||||
? `${stripped.slice(0, 50)}...`
|
||||
: stripped ||
|
||||
t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
}
|
||||
return t({
|
||||
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
|
||||
message: "Cover Letter",
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
|
||||
return match(type)
|
||||
.with("summary", () => undefined)
|
||||
.with("profiles", () => ("username" in item ? item.username : undefined))
|
||||
.with("experience", () => ("position" in item ? item.position : undefined))
|
||||
.with("education", () => ("degree" in item ? item.degree : undefined))
|
||||
.with("projects", () => ("period" in item ? item.period : undefined))
|
||||
.with("skills", () => ("proficiency" in item ? item.proficiency : undefined))
|
||||
.with("languages", () => ("fluency" in item ? item.fluency : undefined))
|
||||
.with("interests", () => undefined)
|
||||
.with("awards", () => ("awarder" in item ? item.awarder : undefined))
|
||||
.with("certifications", () => ("issuer" in item ? item.issuer : undefined))
|
||||
.with("publications", () => ("publisher" in item ? item.publisher : undefined))
|
||||
.with("volunteer", () => ("period" in item ? item.period : undefined))
|
||||
.with("references", () => undefined)
|
||||
.with("cover-letter", () => {
|
||||
if ("content" in item) {
|
||||
const stripped = stripHtml(item.content);
|
||||
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || undefined;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const customSections = resume.data.customSections;
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add Custom Section Button */}
|
||||
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
|
||||
<Trans>Add a new custom section</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionContainer({ section }: { section: CustomSection }) {
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const handleReorder = (items: CustomSectionItemType[]) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
{/* Section Header */}
|
||||
<div className="group flex select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdateSection}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-start transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
|
||||
section.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Badge variant="secondary" className="mb-1.5 rounded-md">
|
||||
{getSectionTitle(section.type)}
|
||||
</Badge>
|
||||
<span className="line-clamp-1 text-wrap font-medium text-base">{section.title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Plural value={section.items.length} one="# item" other="# items" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<CustomSectionDropdownMenu section={section} />
|
||||
</div>
|
||||
|
||||
{/* Section Items */}
|
||||
{section.items.length > 0 && (
|
||||
<div className={cn("border-t", section.hidden && "opacity-50")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type={section.type}
|
||||
item={item}
|
||||
customSectionId={section.id}
|
||||
title={getItemTitle(section.type, item)}
|
||||
subtitle={getItemSubtitle(section.type, item)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Item Button */}
|
||||
<div className="border-t">
|
||||
<SectionAddItemButton type={section.type} customSectionId={section.id}>
|
||||
<Trans>Add a new item</Trans>
|
||||
</SectionAddItemButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onToggleSectionVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].hidden = !draft.customSections[sectionIndex].hidden;
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateSection = () => {
|
||||
openDialog("resume.sections.custom.update", section);
|
||||
};
|
||||
|
||||
const onDuplicateSection = () => {
|
||||
openDialog("resume.sections.custom.create", section);
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
|
||||
if (sectionIndex === -1) return;
|
||||
draft.customSections[sectionIndex].columns = Number.parseInt(value, 10);
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteSection = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
|
||||
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
|
||||
...page,
|
||||
main: page.main.filter((id) => id !== section.id),
|
||||
sidebar: page.sidebar.filter((id) => id !== section.id),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleSectionVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onUpdateSection}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDuplicateSection}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDeleteSection}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { educationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.education;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.education.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="education" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="education" item={item} title={item.school} subtitle={item.degree} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="education">
|
||||
<Trans>Add a new education</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { experienceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.experience;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.experience.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => {
|
||||
return (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="experience"
|
||||
item={item}
|
||||
title={item.company}
|
||||
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="experience">
|
||||
<Trans>Add a new experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { interestItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.interests;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.interests.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="interests" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="interests" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="interests">
|
||||
<Trans>Add a new interest</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { languageItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.languages;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.languages.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="languages" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="languages" item={item} title={item.language} subtitle={item.fluency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="languages">
|
||||
<Trans>Add a new language</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, TrashSimpleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pictureSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PictureSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="picture">
|
||||
<PictureSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type PictureValues = z.infer<typeof pictureSchema>;
|
||||
|
||||
function normalizePictureUrl(url: string, origin: string): string {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("/uploads/")) return `/api${url}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, origin);
|
||||
if (parsed.origin !== origin) return url;
|
||||
if (!parsed.pathname.startsWith("/uploads/")) return url;
|
||||
return `/api${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function PictureSectionForm() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const picture = resume.data.picture;
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
const [pictureSrc, setPictureSrc] = useState("");
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
|
||||
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
|
||||
|
||||
const persist = (data: PictureValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.picture = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: picture,
|
||||
validators: { onChange: pictureSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const onSelectPicture = () => {
|
||||
if (!fileInputRef.current) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const onDeletePicture = () => {
|
||||
if (!picture.url) return;
|
||||
|
||||
const appOrigin = window.location.origin;
|
||||
const pictureUrl = new URL(picture.url, appOrigin);
|
||||
const pictureOrigin = pictureUrl.origin;
|
||||
|
||||
const filename = pictureUrl.pathname.split("/").pop();
|
||||
if (!filename) return;
|
||||
|
||||
// If the picture is from the same origin, attempt to delete it
|
||||
if (pictureOrigin === appOrigin) deleteFile({ filename });
|
||||
|
||||
form.setFieldValue("url", "");
|
||||
handleAutoSave();
|
||||
};
|
||||
|
||||
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const toastId = toast.loading(t`Uploading picture...`);
|
||||
|
||||
uploadFile(file, {
|
||||
onSuccess: ({ url }) => {
|
||||
form.setFieldValue("url", url);
|
||||
handleAutoSave();
|
||||
toast.dismiss(toastId);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getReadableErrorMessage(
|
||||
error,
|
||||
t({
|
||||
comment: "Fallback toast when uploading profile picture for resume fails",
|
||||
message: "Failed to upload picture. Please try again.",
|
||||
}),
|
||||
),
|
||||
{ id: toastId },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedPictureUrl) {
|
||||
setPictureSrc("");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let objectUrl = "";
|
||||
|
||||
void fetch(normalizedPictureUrl, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setPictureSrc(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setPictureSrc(normalizedPictureUrl);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [normalizedPictureUrl]);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={picture.url ? onDeletePicture : onSelectPicture}
|
||||
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
|
||||
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
|
||||
>
|
||||
{(pictureSrc || normalizedPictureUrl) && (
|
||||
<img
|
||||
alt=""
|
||||
src={pictureSrc || normalizedPictureUrl}
|
||||
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
|
||||
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<form.Field name="url">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>URL</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(event) => {
|
||||
field.handleChange(event.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
form.setFieldValue("hidden", !picture.hidden);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<form.Field name="size">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={32}
|
||||
max={512}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="rotation">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Rotation</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
max={360}
|
||||
step={5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>°</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="aspectRatio">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Aspect Ratio</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to square",
|
||||
message: "Square",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(1);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to landscape orientation",
|
||||
message: "Landscape",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(1.5);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-1.5/1 min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t({
|
||||
comment: "Preset button for setting picture aspect ratio to portrait orientation",
|
||||
message: "Portrait",
|
||||
})}
|
||||
onClick={() => {
|
||||
field.handleChange(0.5);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="aspect-1/1.5 min-h-3 min-w-3 border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="borderRadius">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Border Radius</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonGroup className="shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="0pt"
|
||||
onClick={() => {
|
||||
field.handleChange(0);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-none border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="10pt"
|
||||
onClick={() => {
|
||||
field.handleChange(10);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-[10%] border border-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="100pt"
|
||||
onClick={() => {
|
||||
field.handleChange(100);
|
||||
handleAutoSave();
|
||||
}}
|
||||
>
|
||||
<div className="size-3 rounded-full border border-primary" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="borderColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="borderWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Border Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="shadowColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="shadowWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Shadow Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { profileItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.profiles;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.profiles.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="profiles" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="profiles" item={item} title={item.network} subtitle={item.username} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="profiles">
|
||||
<Trans>Add a new profile</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { projectItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.projects;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.projects.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="projects" item={item} title={item.name} subtitle={buildSubtitle(item)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="projects">
|
||||
<Trans>Add a new project</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { publicationItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.publications;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.publications.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="publications" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="publications" item={item} title={item.title} subtitle={item.publisher} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="publications">
|
||||
<Trans>Add a new publication</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { referenceItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.references;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.references.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="references" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="references" item={item} title={item.name} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="references">
|
||||
<Trans>Add a new reference</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { skillItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.skills;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.skills.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{section.items.map((item) => (
|
||||
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="skills">
|
||||
<Trans>Add a new skill</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SummarySectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.summary;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.summary.content = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="summary">
|
||||
<RichInput value={section.content} onChange={onChange} />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.volunteer;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.sections.volunteer.items = items;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="volunteer" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
<AnimatePresence>
|
||||
{section.items.map((item) => (
|
||||
<SectionItem
|
||||
key={item.id}
|
||||
type="volunteer"
|
||||
item={item}
|
||||
title={item.organization}
|
||||
subtitle={item.location}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
|
||||
<SectionAddItemButton type="volunteer">
|
||||
<Trans>Add a new volunteer experience</Trans>
|
||||
</SectionAddItemButton>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume } from "@/components/resume/use-resume";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionDropdownMenu } from "./section-menu";
|
||||
|
||||
type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
type: LeftSidebarSection;
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const resume = useCurrentResume();
|
||||
const data = resume.data;
|
||||
const section =
|
||||
type === "basics"
|
||||
? data.basics
|
||||
: type === "summary"
|
||||
? data.summary
|
||||
: type === "picture"
|
||||
? data.picture
|
||||
: type === "custom"
|
||||
? data.customSections
|
||||
: data.sections[type];
|
||||
|
||||
const isHidden = "hidden" in section && section.hidden;
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? [] : [type]}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
className={cn("space-y-4", isHidden && "opacity-50")}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion-item space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4">
|
||||
{getSectionIcon(type)}
|
||||
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">
|
||||
{("title" in section && section.title) || getSectionTitle(type)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{!["picture", "basics", "custom"].includes(type) && (
|
||||
<SectionDropdownMenu type={type as "summary" | SectionType} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccordionContent
|
||||
className={cn(
|
||||
"p-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type {
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
SectionItem as SectionItemType,
|
||||
SectionType,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { ButtonProps } from "@reactive-resume/ui/components/button";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
CopySimpleIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
FolderPlusIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Reorder, useDragControls } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import {
|
||||
addItemToSection,
|
||||
createCustomSectionWithItem,
|
||||
createPageWithSection,
|
||||
getCompatibleMoveTargets,
|
||||
getSourceSectionTitle,
|
||||
removeItemFromSource,
|
||||
} from "@/libs/resume/move-item";
|
||||
|
||||
// ============================================================================
|
||||
// MoveItemSubmenu Component
|
||||
// ============================================================================
|
||||
|
||||
type MoveItemSubmenuProps = {
|
||||
type: CustomSectionType;
|
||||
item: CustomSectionItem | SectionItemType;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Submenu component for moving items between sections/pages.
|
||||
* Displays compatible targets grouped by page with options to:
|
||||
* - Move to existing compatible section
|
||||
* - Create new section on existing page
|
||||
* - Create new page with new section
|
||||
*/
|
||||
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
/** Compute compatible move targets grouped by page */
|
||||
const moveTargets = useMemo(
|
||||
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
|
||||
[resume, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Get the current section's title (used when creating new sections) */
|
||||
const currentSectionTitle = useMemo(
|
||||
() => getSourceSectionTitle(resume.data, type, customSectionId),
|
||||
[resume, type, customSectionId],
|
||||
);
|
||||
|
||||
/** Handler: Move item to an existing section */
|
||||
const handleMoveToSection = (targetSectionId: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
addItemToSection(draft, removedItem, targetSectionId, type);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new custom section on an existing page and move the item there */
|
||||
const handleNewSectionOnPage = (pageIndex: number) => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createCustomSectionWithItem(draft, removedItem, type, currentSectionTitle, pageIndex);
|
||||
});
|
||||
};
|
||||
|
||||
/** Handler: Create a new page with a new custom section and move the item there */
|
||||
const handleNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
|
||||
if (!removedItem) return;
|
||||
createPageWithSection(draft, removedItem, type, currentSectionTitle);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowBendUpRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Render each page as a submenu */}
|
||||
{moveTargets.map(({ pageIndex, sections }) => (
|
||||
<DropdownMenuSub key={pageIndex}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileIcon />
|
||||
<Trans>Page {pageIndex + 1}</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{/* Existing compatible sections on this page */}
|
||||
{sections.map(({ sectionId, sectionTitle }) => (
|
||||
<DropdownMenuItem key={sectionId} onClick={() => handleMoveToSection(sectionId)}>
|
||||
{sectionTitle}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{/* Separator if there are existing sections */}
|
||||
{sections.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{/* Option to create a new section on this page */}
|
||||
<DropdownMenuItem onClick={() => handleNewSectionOnPage(pageIndex)}>
|
||||
<FolderPlusIcon />
|
||||
<Trans>New Section</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Option to create a new page with a new section */}
|
||||
<DropdownMenuItem onClick={handleNewPage}>
|
||||
<PlusCircleIcon />
|
||||
<Trans>New Page</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SectionItem Component
|
||||
// ============================================================================
|
||||
|
||||
type Props<T extends CustomSectionItem | SectionItemType> = {
|
||||
type: CustomSectionType;
|
||||
item: T;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
type,
|
||||
item,
|
||||
title,
|
||||
subtitle,
|
||||
customSectionId,
|
||||
}: Props<T>) {
|
||||
const confirm = useConfirm();
|
||||
const controls = useDragControls();
|
||||
const { openDialog } = useDialogStore();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items[index].hidden = !section.items[index].hidden;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
// Type assertion needed because TypeScript can't narrow the union type through template literals
|
||||
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when deleting a section item in resume builder",
|
||||
message: "Delete",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
} else {
|
||||
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
|
||||
const section = draft.sections[type as SectionType];
|
||||
if (!("items" in section)) return;
|
||||
const index = section.items.findIndex((_item) => _item.id === item.id);
|
||||
if (index === -1) return;
|
||||
section.items.splice(index, 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={item.id}
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="group relative flex h-18 select-none border-b will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<DotsSixVerticalIcon />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpdate}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-start justify-center space-y-0.5 ps-1.5 text-start opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
|
||||
item.hidden && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="line-clamp-1 font-medium">{title}</div>
|
||||
{subtitle && <div className="line-clamp-1 text-muted-foreground text-xs">{subtitle}</div>}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleVisibility}>
|
||||
{item.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{item.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onUpdate}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDuplicate}>
|
||||
<CopySimpleIcon />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<TrashSimpleIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type AddButtonProps = Omit<ButtonProps, "type"> & {
|
||||
type: CustomSectionType | "custom";
|
||||
customSectionId?: string;
|
||||
};
|
||||
|
||||
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (type === "custom") {
|
||||
openDialog("resume.sections.custom.create", undefined);
|
||||
} else {
|
||||
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleAdd}
|
||||
className={cn("h-12 w-full justify-start rounded-t-none", className)}
|
||||
{...props}
|
||||
>
|
||||
<PlusIcon />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BroomIcon,
|
||||
ColumnsIcon,
|
||||
EyeClosedIcon,
|
||||
EyeIcon,
|
||||
ListIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
|
||||
type Props = {
|
||||
type: "summary" | SectionType;
|
||||
};
|
||||
|
||||
export function SectionDropdownMenu({ type }: Props) {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const resume = useCurrentResume();
|
||||
const section = type === "summary" ? resume.data.summary : resume.data.sections[type];
|
||||
|
||||
const onAddItem = () => {
|
||||
if (type === "summary") return;
|
||||
openDialog(`resume.sections.${type}.create`, undefined);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => {
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.hidden = !draft.summary.hidden;
|
||||
} else {
|
||||
draft.sections[type].hidden = !draft.sections[type].hidden;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onRenameSection = async () => {
|
||||
const newTitle = await prompt(t`What do you want to rename this section to?`, {
|
||||
description: t`Leave empty to reset the title to the original.`,
|
||||
defaultValue: section.title,
|
||||
});
|
||||
|
||||
if (newTitle === null || newTitle === section.title) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.title = newTitle ?? "";
|
||||
} else {
|
||||
draft.sections[type].title = newTitle ?? "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSetColumns = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.columns = Number.parseInt(value, 10);
|
||||
} else {
|
||||
draft.sections[type].columns = Number.parseInt(value, 10);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onReset = async () => {
|
||||
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
|
||||
description: t`This will remove all items from this section.`,
|
||||
confirmText: t({
|
||||
comment: "Destructive confirmation button label when resetting a resume section",
|
||||
message: "Reset",
|
||||
}),
|
||||
cancelText: t({
|
||||
comment: "Confirmation dialog button label to abort resetting a resume section",
|
||||
message: "Cancel",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
if (type === "summary") {
|
||||
draft.summary.content = "";
|
||||
} else {
|
||||
draft.sections[type].items = [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<ListIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
{type !== "summary" && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onAddItem}>
|
||||
<PlusIcon />
|
||||
<Trans>Add a new item</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onToggleVisibility}>
|
||||
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
|
||||
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onRenameSection}>
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ColumnsIcon />
|
||||
<Trans>Columns</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
|
||||
{[1, 2, 3, 4, 5, 6].map((column) => (
|
||||
<DropdownMenuRadioItem key={column} value={column.toString()}>
|
||||
<Plural value={column} one="# Column" other="# Columns" />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive" onClick={onReset}>
|
||||
<BroomIcon />
|
||||
<Trans>Reset</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { DesignSectionBuilder } from "./sections/design";
|
||||
import { ExportSectionBuilder } from "./sections/export";
|
||||
import { InformationSectionBuilder } from "./sections/information";
|
||||
import { LayoutSectionBuilder } from "./sections/layout";
|
||||
import { NotesSectionBuilder } from "./sections/notes";
|
||||
import { PageSectionBuilder } from "./sections/page";
|
||||
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
|
||||
import { SharingSectionBuilder } from "./sections/sharing";
|
||||
import { StatisticsSectionBuilder } from "./sections/statistics";
|
||||
import { TemplateSectionBuilder } from "./sections/template";
|
||||
import { TypographySectionBuilder } from "./sections/typography";
|
||||
|
||||
function getSectionComponent(type: RightSidebarSection) {
|
||||
return match(type)
|
||||
.with("template", () => <TemplateSectionBuilder />)
|
||||
.with("layout", () => <LayoutSectionBuilder />)
|
||||
.with("typography", () => <TypographySectionBuilder />)
|
||||
.with("design", () => <DesignSectionBuilder />)
|
||||
.with("page", () => <PageSectionBuilder />)
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
.with("statistics", () => <StatisticsSectionBuilder />)
|
||||
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
|
||||
.with("export", () => <ExportSectionBuilder />)
|
||||
.with("information", () => <InformationSectionBuilder />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function BuilderSidebarRight() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="@container h-[calc(100svh-3.5rem)] overflow-hidden bg-background sm:me-12"
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<Copyright className="mx-auto py-2 text-center" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: RightSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("right", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<BuilderSidebarEdge side="right">
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BuilderSidebarEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { LevelTypeCombobox } from "@/components/level/combobox";
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function DesignSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="design" className="space-y-6">
|
||||
<ColorSectionForm />
|
||||
<Separator />
|
||||
<LevelSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorValues = z.infer<typeof colorDesignSchema>;
|
||||
|
||||
function ColorSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: ColorValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.colors = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: colors,
|
||||
validators: { onChange: colorDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="flex flex-wrap gap-2.5 p-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
{quickColorOptions.map((color) => (
|
||||
<QuickColorCircle
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === field.state.value}
|
||||
onSelect={(color) => {
|
||||
field.handleChange(color as string);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Primary Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
value={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="text">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="background">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const quickColorOptions = [
|
||||
"rgba(231, 0, 11, 1)", // red-600
|
||||
"rgba(245, 73, 0, 1)", // orange-600
|
||||
"rgba(225, 113, 0, 1)", // amber-600
|
||||
"rgba(208, 135, 0, 1)", // yellow-600
|
||||
"rgba(94, 165, 0, 1)", // lime-600
|
||||
"rgba(0, 166, 62, 1)", // green-600
|
||||
"rgba(0, 153, 102, 1)", // emerald-600
|
||||
"rgba(0, 150, 137, 1)", // teal-600
|
||||
"rgba(0, 146, 184, 1)", // cyan-600
|
||||
"rgba(0, 132, 209, 1)", // sky-600
|
||||
"rgba(21, 93, 252, 1)", // blue-600
|
||||
"rgba(79, 57, 246, 1)", // indigo-600
|
||||
"rgba(127, 34, 254, 1)", // violet-600
|
||||
"rgba(152, 16, 250, 1)", // purple-600
|
||||
"rgba(200, 0, 222, 1)", // fuchsia-600
|
||||
"rgba(230, 0, 118, 1)", // pink-600
|
||||
"rgba(236, 0, 63, 1)", // rose-600
|
||||
"rgba(69, 85, 108, 1)", // slate-600
|
||||
"rgba(74, 85, 101, 1)", // gray-600
|
||||
"rgba(82, 82, 92, 1)", // zinc-600
|
||||
"rgba(82, 82, 82, 1)", // neutral-600
|
||||
"rgba(87, 83, 77, 1)", // stone-600
|
||||
];
|
||||
|
||||
type QuickColorCircleProps = React.ComponentProps<"button"> & {
|
||||
color: string;
|
||||
active: boolean;
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
function QuickColorCircle({ color, active, onSelect, className, ...props }: QuickColorCircleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
|
||||
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LevelValues = z.infer<typeof levelDesignSchema>;
|
||||
type LevelType = LevelValues["type"];
|
||||
|
||||
function LevelSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const levelDesign = resume.data.metadata.design.level;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: LevelValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.level = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: levelDesign,
|
||||
validators: { onChange: levelDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const previewType = useStore(form.store, (s) => s.values.type);
|
||||
const previewIcon = useStore(form.store, (s) => s.values.icon);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<h4 className="font-semibold text-lg leading-none tracking-tight">
|
||||
<Trans>Level</Trans>
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
||||
className="flex items-center justify-center rounded-md p-6"
|
||||
>
|
||||
<LevelDisplay level={3} type={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<form.Field name="icon">
|
||||
{(field) => (
|
||||
<FormItem className="shrink-0" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Icon</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker
|
||||
size="icon"
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="type">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<LevelTypeCombobox
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
field.handleChange(value as LevelType);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resumeData = useResume();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const resume = resumeData;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="export" className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadJSON}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileJsIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">JSON</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
|
||||
other applications, including AI assistants.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
{isPrinting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">PDF</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
|
||||
with recruiters.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function InformationSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="information" className="space-y-4">
|
||||
<div className="space-y-2 rounded-md border bg-sky-600 p-5 text-white dark:bg-sky-700">
|
||||
<h4 className="font-medium tracking-tight">
|
||||
<Trans>Support the app by doing what you can!</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 text-xs leading-normal">
|
||||
<Trans>
|
||||
<p>
|
||||
Thank you for using Reactive Resume! This app is a labor of love, created mostly in my spare time, with
|
||||
wonderful support from open-source contributors around the world.
|
||||
</p>
|
||||
<p>
|
||||
If Reactive Resume has been helpful to you, and you'd like to help keep it free and open for everyone,
|
||||
please consider making a donation. Every little bit is appreciated!
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
nativeButton={false}
|
||||
className="mt-2 whitespace-normal px-4! text-xs"
|
||||
render={
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<HandHeartIcon />
|
||||
<span className="truncate">
|
||||
<Trans>Donate to Reactive Resume</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<Trans>Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Source Code</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener">
|
||||
<Trans>Report a Bug</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Translations</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Sponsors</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { metadataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../../shared/section-base";
|
||||
import { LayoutPages } from "./pages";
|
||||
|
||||
export function LayoutSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="layout" className="space-y-4">
|
||||
<LayoutPages />
|
||||
<LayoutSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = metadataSchema.shape.layout.omit({ pages: true });
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function LayoutSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.sidebarWidth = data.sidebarWidth;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { sidebarWidth: layout.sidebarWidth },
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="sidebarWidth">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Sidebar Width</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.01}
|
||||
value={[field.state.value]}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(Array.isArray(value) ? value[0] : value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroup className="w-auto shrink-0">
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>%</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { forwardRef, useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
|
||||
type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the primary content area",
|
||||
message: "Main",
|
||||
}),
|
||||
)
|
||||
.with("sidebar", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the secondary sidebar area",
|
||||
message: "Sidebar",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
type PageLocation = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
* Format: "page-{index}-{columnId}" or "{sectionId}"
|
||||
*/
|
||||
const parseDroppableId = (id: string): PageLocation | null => {
|
||||
if (id.startsWith("page-")) {
|
||||
const parts = id.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const pageIndex = Number.parseInt(parts[1] ?? "0", 10);
|
||||
const columnId = parts[2] as ColumnId;
|
||||
if (!Number.isNaN(pageIndex) && (columnId === "main" || columnId === "sidebar")) {
|
||||
return { pageIndex, columnId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const createDroppableId = (pageIndex: number, columnId: ColumnId): string => {
|
||||
return `page-${pageIndex}-${columnId}`;
|
||||
};
|
||||
|
||||
export function LayoutPages() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const template = resume.data.metadata.template;
|
||||
const templateSidebarPosition = templates[template].sidebarPosition;
|
||||
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
*/
|
||||
const findContainer = useCallback(
|
||||
(id: string): PageLocation | null => {
|
||||
// Check if it's a droppable ID
|
||||
const location = parseDroppableId(id);
|
||||
if (location) return location;
|
||||
|
||||
// Search through all pages
|
||||
for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
|
||||
const page = layout.pages[pageIndex];
|
||||
if (page.main.includes(id)) return { pageIndex, columnId: "main" };
|
||||
if (page.sidebar.includes(id)) return { pageIndex, columnId: "sidebar" };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[layout.pages],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => setActiveId(String(event.active.id)), []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = String(active.id);
|
||||
const overIdStr = String(over.id);
|
||||
|
||||
if (activeIdStr === overIdStr) return;
|
||||
|
||||
const activeLocation = findContainer(activeIdStr);
|
||||
const overLocation = parseDroppableId(overIdStr) ?? findContainer(overIdStr);
|
||||
|
||||
if (!activeLocation || !overLocation) return;
|
||||
|
||||
// Same location, reorder within column
|
||||
if (activeLocation.pageIndex === overLocation.pageIndex && activeLocation.columnId === overLocation.columnId) {
|
||||
const page = layout.pages[activeLocation.pageIndex];
|
||||
const items = page[activeLocation.columnId];
|
||||
const oldIdx = items.indexOf(activeIdStr);
|
||||
let newIdx = items.indexOf(overIdStr);
|
||||
if (oldIdx === -1 || oldIdx === newIdx) return;
|
||||
if (newIdx === -1) newIdx = items.length - 1;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const colOrder = draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId];
|
||||
draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId] = arrayMove(
|
||||
colOrder,
|
||||
oldIdx,
|
||||
newIdx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Different location, move between columns/pages
|
||||
const fromPage = layout.pages[activeLocation.pageIndex];
|
||||
const toPage = layout.pages[overLocation.pageIndex];
|
||||
const fromItems = fromPage[activeLocation.columnId];
|
||||
const toItems = toPage[overLocation.columnId];
|
||||
const fromIdx = fromItems.indexOf(activeIdStr);
|
||||
if (fromIdx === -1) return;
|
||||
|
||||
let toIdx = toItems.indexOf(overIdStr);
|
||||
if (toIdx === -1) toIdx = toItems.length;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const fromPageDraft = draft.metadata.layout.pages[activeLocation.pageIndex];
|
||||
const toPageDraft = draft.metadata.layout.pages[overLocation.pageIndex];
|
||||
const from = fromPageDraft[activeLocation.columnId];
|
||||
const to = toPageDraft[overLocation.columnId];
|
||||
|
||||
from.splice(fromIdx, 1);
|
||||
to.splice(Math.min(toIdx, to.length), 0, activeIdStr);
|
||||
});
|
||||
},
|
||||
[findContainer, layout.pages, updateResumeData],
|
||||
);
|
||||
|
||||
const handleAddPage = useCallback(() => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: [],
|
||||
sidebar: [],
|
||||
});
|
||||
});
|
||||
}, [updateResumeData]);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(pageIndex: number) => {
|
||||
if (layout.pages.length <= 1) return; // Don't allow deleting the last page
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const pageToDelete = draft.metadata.layout.pages[pageIndex];
|
||||
// Find the first available page that isn't being deleted
|
||||
const targetPageIndex = pageIndex === 0 ? 1 : 0;
|
||||
const targetPage = draft.metadata.layout.pages[targetPageIndex];
|
||||
|
||||
// Move all sections from deleted page to target page
|
||||
targetPage.main.push(...pageToDelete.main);
|
||||
targetPage.sidebar.push(...pageToDelete.sidebar);
|
||||
|
||||
draft.metadata.layout.pages.splice(pageIndex, 1);
|
||||
});
|
||||
},
|
||||
[layout.pages.length, updateResumeData],
|
||||
);
|
||||
|
||||
const handleToggleFullWidth = useCallback(
|
||||
(pageIndex: number, fullWidth: boolean) => {
|
||||
updateResumeData((draft) => {
|
||||
const page = draft.metadata.layout.pages[pageIndex];
|
||||
page.fullWidth = fullWidth;
|
||||
|
||||
if (fullWidth) {
|
||||
// Move all sidebar sections to main
|
||||
page.main.push(...page.sidebar);
|
||||
page.sidebar = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateResumeData],
|
||||
);
|
||||
|
||||
// Don't render until pages are initialized
|
||||
if (layout.pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="builder-layout"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{layout.pages.map((page, pageIndex) => (
|
||||
<PageContainer
|
||||
key={`page-${pageIndex}`}
|
||||
pageIndex={pageIndex}
|
||||
page={page}
|
||||
canDelete={layout.pages.length > 1}
|
||||
sidebarPosition={templateSidebarPosition}
|
||||
onDelete={handleDeletePage}
|
||||
onToggleFullWidth={handleToggleFullWidth}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="self-end" onClick={handleAddPage}>
|
||||
<PlusIcon />
|
||||
<Trans>Add Page</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
|
||||
canDelete: boolean;
|
||||
sidebarPosition: "left" | "right" | "none";
|
||||
onDelete: (pageIndex: number) => void;
|
||||
onToggleFullWidth: (pageIndex: number, fullWidth: boolean) => void;
|
||||
};
|
||||
|
||||
function PageContainer({
|
||||
pageIndex,
|
||||
page,
|
||||
canDelete,
|
||||
sidebarPosition,
|
||||
onDelete,
|
||||
onToggleFullWidth,
|
||||
}: PageContainerProps) {
|
||||
const isFullWidth = page.fullWidth;
|
||||
const fullWidthSwitchId = useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="font-medium text-xs">
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
|
||||
<TrashIcon />
|
||||
<Trans>Delete Page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full @md:grid-cols-2 gap-x-4 gap-y-2 p-4 pt-0 font-medium",
|
||||
sidebarPosition === "none" && "@md:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="main"
|
||||
items={page.main}
|
||||
disabled={false}
|
||||
className={cn(sidebarPosition === "left" ? "order-2" : "order-1")}
|
||||
/>
|
||||
|
||||
{!isFullWidth && (
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="sidebar"
|
||||
items={page.sidebar}
|
||||
hideLabel={sidebarPosition === "none"}
|
||||
className={cn(sidebarPosition === "left" ? "order-1" : "order-2")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutColumnProps = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
items: string[];
|
||||
hideLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function LayoutColumn({
|
||||
pageIndex,
|
||||
columnId,
|
||||
items,
|
||||
hideLabel = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: LayoutColumnProps) {
|
||||
const droppableId = createDroppableId(pageIndex, columnId);
|
||||
const { setNodeRef, isOver } = useDroppable({ id: droppableId, disabled });
|
||||
|
||||
return (
|
||||
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn("space-y-1.5", disabled && "opacity-50", className)}>
|
||||
{!hideLabel && <div className="@md:row-start-1 ps-4 font-medium text-xs">{getColumnLabel(columnId)}</div>}
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"space-y-2.5 rounded-md border border-dashed p-3 pb-8 transition-colors",
|
||||
isOver && !disabled ? "border-primary/60 bg-primary/5" : "bg-background/40",
|
||||
)}
|
||||
>
|
||||
{items.map((id) => (
|
||||
<SortableLayoutItem key={id} id={id} pageIndex={pageIndex} columnId={columnId} />
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 font-medium text-muted-foreground text-xs">
|
||||
<Trans>Drag and drop sections here to move them between columns</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
|
||||
type SortableLayoutItemProps = {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
};
|
||||
|
||||
const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
|
||||
({ id, isDragging, isOverlay, className, style, ...rest }, ref) => {
|
||||
const resume = useCurrentResume();
|
||||
const title = (() => {
|
||||
if (!resume) return id;
|
||||
if (id === "summary") return resume.data.summary.title || getSectionTitle("summary");
|
||||
if (id in resume.data.sections)
|
||||
return resume.data.sections[id as SectionType].title || getSectionTitle(id as SectionType);
|
||||
const customSection = resume.data.customSections.find((section) => section.id === id);
|
||||
if (customSection) return customSection.title;
|
||||
return id;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-overlay={isOverlay ? "true" : undefined}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
|
||||
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LayoutItemContent.displayName = "LayoutItemContent";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function NotesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="notes">
|
||||
<NotesSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const notes = resume.data.metadata.notes;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.notes = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
<Trans>
|
||||
This section is reserved for your personal notes specific to this resume. The content here remains private and
|
||||
is not shared with anyone else.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<RichInput value={notes} onChange={onChange} />
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>
|
||||
For example, information regarding which companies you sent this resume to or the links to the job
|
||||
descriptions can be noted down here.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { pageSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { getLocaleOptions } from "@/components/locale/combobox";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PageSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="page">
|
||||
<PageSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = pageSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function PageSectionForm() {
|
||||
const resume = useResume();
|
||||
const page = resume?.data.metadata.page;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.page = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: page,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = <K extends keyof FormValues>(name: K, value: FormValues[K]) => {
|
||||
persist({ ...form.state.values, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="locale">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={getLocaleOptions()}
|
||||
value={field.state.value}
|
||||
onValueChange={(locale) => {
|
||||
const value = (locale ?? "") as string;
|
||||
field.handleChange(value);
|
||||
handleAutoSave("locale", value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="format">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans context="Page Format (A4, Letter)">Format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "a4", label: t`A4` },
|
||||
{ value: "letter", label: t`Letter` },
|
||||
]}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
const format = value as FormValues["format"];
|
||||
field.handleChange(format);
|
||||
handleAutoSave("format", format);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginX);
|
||||
handleAutoSave("marginX", marginX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginY);
|
||||
handleAutoSave("marginY", marginY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapX);
|
||||
handleAutoSave("gapX", gapX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapY);
|
||||
handleAutoSave("gapY", gapY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="hideIcons">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full flex items-center gap-x-3 py-2"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Switch
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked);
|
||||
handleAutoSave("hideIcons", checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hide all icons on the resume</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
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 { useAIStore } from "@reactive-resume/ai/store";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
function impactCircleClass(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () => "bg-rose-600")
|
||||
.with("medium", () => "bg-amber-600")
|
||||
.with("low", () => "bg-emerald-600")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function impactLabel(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "High",
|
||||
}),
|
||||
)
|
||||
.with("medium", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Medium",
|
||||
}),
|
||||
)
|
||||
.with("low", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Low",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function ResumeAnalysisSectionBuilder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const resume = useResume();
|
||||
const aiEnabled = useAIStore((state) => state.enabled);
|
||||
const aiProvider = useAIStore((state) => state.provider);
|
||||
const aiModel = useAIStore((state) => state.model);
|
||||
const aiApiKey = useAIStore((state) => state.apiKey);
|
||||
const aiBaseURL = useAIStore((state) => state.baseURL);
|
||||
|
||||
const resumeId = resume?.id ?? "";
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
|
||||
enabled: !!resume,
|
||||
});
|
||||
|
||||
const { mutate: analyzeResume, isPending } = useMutation({
|
||||
...orpc.ai.analyzeResume.mutationOptions(),
|
||||
onSuccess: (analysis) => {
|
||||
queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resumeId } }), analysis);
|
||||
toast.success(t`Resume analysis complete.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to analyze resume.`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI returns invalid resume analysis format",
|
||||
message: "The AI returned an invalid analysis format. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider cannot be reached during resume analysis",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when resume analysis request fails",
|
||||
message: "Something went wrong while analyzing your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const analysis = analysisQuery.data;
|
||||
const score = analysis?.overallScore ?? 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 onAnalyze = () => {
|
||||
if (!resume) return;
|
||||
|
||||
analyzeResume({
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
apiKey: aiApiKey,
|
||||
baseURL: aiBaseURL,
|
||||
resumeId: resume.id,
|
||||
resumeData: resume.data,
|
||||
});
|
||||
};
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="analysis" className="space-y-4">
|
||||
{!aiEnabled && <DisabledState />}
|
||||
|
||||
{aiEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 rounded-md border bg-card p-3">
|
||||
<div className="grid grid-cols-2 items-center gap-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Get a review of your resume with an overall score, strengths, and actionable suggestions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button disabled={isPending} onClick={onAnalyze} className="ml-auto w-fit">
|
||||
<SparkleIcon />
|
||||
{analyzeLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<div
|
||||
className={`grid size-18 place-items-center rounded-full border-3 border-background font-bold text-lg text-white ${scoreTone}`}
|
||||
>
|
||||
{score ?? "--"}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>Overall Score</Trans>
|
||||
</p>
|
||||
<div className="grid grid-cols-10 gap-1">
|
||||
{Array.from({ length: 10 }).map((_, index) => {
|
||||
const active = score != null && index < Math.round(score / 10);
|
||||
return (
|
||||
<div
|
||||
key={`scorebar-${index}`}
|
||||
className={`h-1.5 rounded-full transition-colors ${active ? "bg-primary" : "bg-muted"}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{analysis?.updatedAt && (
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
<Trans>Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisQuery.isFetched && !analysis && !isPending && (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="max-w-xs text-muted-foreground text-sm">
|
||||
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="flex items-center gap-2 font-semibold text-sm">
|
||||
<LightningIcon className="text-primary" />
|
||||
<Trans>Scorecard</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.scorecard.map((item) => (
|
||||
<div key={item.dimension} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-sm">{item.dimension}</div>
|
||||
<Badge variant="secondary">{item.score}/100</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{item.rationale}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Strengths</Trans>
|
||||
</h5>
|
||||
|
||||
<ul className="list-outside list-disc pl-5 text-muted-foreground text-sm">
|
||||
{analysis.strengths.map((strength) => (
|
||||
<li key={strength} className="py-1.5">
|
||||
{strength}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<div className="space-y-4 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Suggestions</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.suggestions.map((suggestion) => (
|
||||
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">{suggestion.why}</div>
|
||||
|
||||
{suggestion.exampleRewrite && (
|
||||
<div className="rounded bg-muted p-2 text-muted-foreground text-xs">
|
||||
{suggestion.exampleRewrite}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
<Trans>
|
||||
Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
|
||||
suggestions. To activate this feature, please update your AI settings.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/settings/integrations">
|
||||
<Trans>Open Integrations Settings</Trans>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
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 { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SharingSectionBuilder() {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
|
||||
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
|
||||
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 onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onTogglePublic = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
const updated = await updateResume({ id: resume.id, isPublic: checked });
|
||||
patchResume((draft) => {
|
||||
draft.isPublic = updated.isPublic;
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[patchResume, resume.id, updateResume],
|
||||
);
|
||||
|
||||
const onSetPassword = useCallback(async () => {
|
||||
const value = await prompt(t`Protect your resume from unauthorized access with a password`, {
|
||||
description: t`Anyone visiting the resume's public URL must enter this password to access it.`,
|
||||
confirmText: t`Set Password`,
|
||||
inputProps: {
|
||||
type: "password",
|
||||
minLength: 6,
|
||||
maxLength: 64,
|
||||
},
|
||||
});
|
||||
if (!value) return;
|
||||
|
||||
const password = value.trim();
|
||||
if (!password) return toast.error(t`Password cannot be empty.`);
|
||||
|
||||
const toastId = toast.loading(t`Enabling password protection...`);
|
||||
|
||||
try {
|
||||
await setPassword({ id: resume.id, password });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = true;
|
||||
});
|
||||
toast.success(t`Password protection has been enabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [patchResume, prompt, resume.id, setPassword]);
|
||||
|
||||
const onRemovePassword = useCallback(async () => {
|
||||
if (!resume.hasPassword) return;
|
||||
|
||||
const confirmation = await confirm(t`Are you sure you want to remove password protection?`, {
|
||||
description: t`Anyone who has the resume's public URL will be able to view and download your resume without entering a password.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Removing password protection...`);
|
||||
|
||||
try {
|
||||
await removePassword({ id: resume.id });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = false;
|
||||
});
|
||||
toast.success(t`Password protection has been disabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [confirm, patchResume, removePassword, resume.hasPassword, resume.id]);
|
||||
|
||||
const isPasswordProtected = resume.hasPassword;
|
||||
|
||||
return (
|
||||
<SectionBase type="sharing" className="space-y-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="sharing-switch"
|
||||
checked={resume.isPublic}
|
||||
onCheckedChange={(checked) => void onTogglePublic(checked)}
|
||||
/>
|
||||
|
||||
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
|
||||
<span className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>Anyone with the link can view and download the resume.</Trans>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{resume.isPublic && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sharing-url">
|
||||
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
|
||||
<ClipboardIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{isPasswordProtected ? (
|
||||
<Trans>
|
||||
Your resume's public link is currently protected by a password. Share the password only with people you
|
||||
trust.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Optionally, set a password so that only people with the password can view your resume through the link.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isPasswordProtected ? (
|
||||
<Button variant="outline" onClick={onRemovePassword}>
|
||||
<LockSimpleOpenIcon />
|
||||
<Trans>Remove Password</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onSetPassword}>
|
||||
<LockSimpleIcon />
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function StatisticsSectionBuilder() {
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { data: statistics } = useQuery(
|
||||
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
|
||||
);
|
||||
|
||||
if (!statistics) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="statistics">
|
||||
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
|
||||
<AccordionItem value="isPrivate">
|
||||
<AccordionContent className="pb-0">
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Track your resume's views and downloads</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can
|
||||
see your resume's statistics.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="isPublic">
|
||||
<AccordionContent className="grid @md:grid-cols-2 grid-cols-1 gap-4 pb-0">
|
||||
<StatisticsItem
|
||||
label={t`Views`}
|
||||
value={statistics.views}
|
||||
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
|
||||
/>
|
||||
|
||||
<StatisticsItem
|
||||
label={t`Downloads`}
|
||||
value={statistics.downloads}
|
||||
timestamp={
|
||||
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
|
||||
}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type StatisticsItemProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mb-1 font-bold font-mono text-4xl">{value}</h4>
|
||||
<p className="font-medium text-muted-foreground leading-none">{label}</p>
|
||||
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useCurrentResume } from "@/components/resume/use-resume";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
<TemplateSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSectionForm() {
|
||||
const { i18n } = useLingui();
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
const resume = useCurrentResume();
|
||||
const template = resume.data.metadata.template;
|
||||
|
||||
const metadata = templates[template];
|
||||
|
||||
const onOpenTemplateGallery = () => {
|
||||
openDialog("resume.template.gallery", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-4 @md:pt-1 @md:pb-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">{metadata.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">{i18n.t(metadata.description)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{metadata.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { typographySchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TypographySectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="typography">
|
||||
<TypographySectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = typographySchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FontWeight = FormValues["body"]["fontWeights"][number];
|
||||
|
||||
function TypographySectionForm() {
|
||||
const resume = useResume();
|
||||
const typography = resume?.data.metadata.typography;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.typography.body = data.body;
|
||||
draft.metadata.typography.heading = data.heading;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: typography,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const bodyFontFamily = useStore(form.store, (s) => s.values.body.fontFamily);
|
||||
const headingFontFamily = useStore(form.store, (s) => s.values.heading.fontFamily);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="body.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("body.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weights</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={bodyFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="heading.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("heading.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weight</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={headingFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
|
||||
type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
type: RightSidebarSection;
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
className="space-y-4"
|
||||
id={`sidebar-${type}`}
|
||||
value={collapsed ? [] : [type]}
|
||||
onValueChange={() => toggleCollapsed(type)}
|
||||
>
|
||||
<AccordionItem value={type} className="group/accordion-item space-y-4">
|
||||
<div className="flex items-center">
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4">
|
||||
{getSectionIcon(type)}
|
||||
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">{getSectionTitle(type)}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccordionContent
|
||||
className={cn(
|
||||
"overflow-hidden pb-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SidebarSection } from "@/libs/resume/section";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { leftSidebarSections, rightSidebarSections } from "@/libs/resume/section";
|
||||
|
||||
type SectionCollapseState = {
|
||||
[id in SidebarSection]?: { collapsed: boolean };
|
||||
};
|
||||
|
||||
type SectionStoreState = {
|
||||
sections: SectionCollapseState;
|
||||
};
|
||||
|
||||
type SectionStoreActions = {
|
||||
setCollapsed: (id: SidebarSection, collapsed: boolean) => void;
|
||||
toggleCollapsed: (id: SidebarSection) => void;
|
||||
toggleAll: () => void;
|
||||
};
|
||||
|
||||
type SectionStore = SectionStoreState & SectionStoreActions;
|
||||
|
||||
export const useSectionStore = create<SectionStore>()(
|
||||
persist(
|
||||
immer((set) => ({
|
||||
sections: {},
|
||||
setCollapsed: (id, collapsed) => {
|
||||
set((state) => {
|
||||
state.sections[id] = { collapsed };
|
||||
});
|
||||
},
|
||||
toggleCollapsed: (id) => {
|
||||
set((state) => {
|
||||
const current = state.sections[id]?.collapsed ?? false;
|
||||
state.sections[id] = { collapsed: !current };
|
||||
});
|
||||
},
|
||||
toggleAll: () => {
|
||||
set((state) => {
|
||||
[...leftSidebarSections, ...rightSidebarSections].forEach((id) => {
|
||||
const current = state.sections[id]?.collapsed ?? false;
|
||||
state.sections[id] = { collapsed: !current };
|
||||
});
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "section-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
sections: state.sections,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Layout, usePanelRef } from "react-resizable-panels";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { create } from "zustand/react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
export type BuilderLayout = {
|
||||
left: number;
|
||||
artboard: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
|
||||
left: 22,
|
||||
artboard: 56,
|
||||
right: 22,
|
||||
};
|
||||
|
||||
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
|
||||
const left = layout.left;
|
||||
const artboard = layout.artboard;
|
||||
const right = layout.right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
};
|
||||
|
||||
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
|
||||
if (!value) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
|
||||
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
const left = (parsed as { left?: unknown }).left;
|
||||
const artboard = (parsed as { artboard?: unknown }).artboard;
|
||||
const right = (parsed as { right?: unknown }).right;
|
||||
|
||||
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
|
||||
return { left, artboard, right };
|
||||
} catch {
|
||||
return DEFAULT_BUILDER_LAYOUT;
|
||||
}
|
||||
};
|
||||
|
||||
interface BuilderSidebarState {
|
||||
layout: BuilderLayout;
|
||||
leftSidebar: PanelImperativeHandle | null;
|
||||
rightSidebar: PanelImperativeHandle | null;
|
||||
}
|
||||
|
||||
interface BuilderSidebarActions {
|
||||
setLayout: (layout: BuilderLayout) => void;
|
||||
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
|
||||
}
|
||||
|
||||
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
|
||||
|
||||
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
|
||||
layout: DEFAULT_BUILDER_LAYOUT,
|
||||
leftSidebar: null,
|
||||
rightSidebar: null,
|
||||
setLayout: (layout) => set({ layout }),
|
||||
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
|
||||
setRightSidebar: (ref) => set({ rightSidebar: ref }),
|
||||
}));
|
||||
|
||||
type UseBuilderSidebarReturn = {
|
||||
maxSidebarSize: string | number;
|
||||
collapsedSidebarSize: number;
|
||||
isCollapsed: (side: "left" | "right") => boolean;
|
||||
toggleSidebar: (side: "left" | "right", forceState?: boolean) => void;
|
||||
};
|
||||
|
||||
export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (builder: UseBuilderSidebarReturn) => T): T {
|
||||
const isMobile = useIsMobile();
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const maxSidebarSize = useMemo((): string | number => {
|
||||
if (!width) return 0;
|
||||
return isMobile ? "95%" : "45%";
|
||||
}, [width, isMobile]);
|
||||
|
||||
const collapsedSidebarSize = useMemo((): number => {
|
||||
if (!width) return 0;
|
||||
return isMobile ? 0 : 48;
|
||||
}, [width, isMobile]);
|
||||
|
||||
const expandSize = useMemo(() => (isMobile ? "95%" : "30%"), [isMobile]);
|
||||
|
||||
const isCollapsed = useCallback((side: "left" | "right") => {
|
||||
const sidebar =
|
||||
side === "left"
|
||||
? useBuilderSidebarStore.getState().leftSidebar?.current
|
||||
: useBuilderSidebarStore.getState().rightSidebar?.current;
|
||||
|
||||
if (!sidebar) return false;
|
||||
return sidebar.isCollapsed();
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(
|
||||
(side: "left" | "right", forceState?: boolean) => {
|
||||
const sidebar =
|
||||
side === "left"
|
||||
? useBuilderSidebarStore.getState().leftSidebar?.current
|
||||
: useBuilderSidebarStore.getState().rightSidebar?.current;
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
const shouldExpand = forceState === undefined ? sidebar.isCollapsed() : forceState;
|
||||
|
||||
if (shouldExpand) sidebar.resize(expandSize);
|
||||
else sidebar.collapse();
|
||||
},
|
||||
[expandSize],
|
||||
);
|
||||
|
||||
const state = useMemo(() => {
|
||||
return {
|
||||
maxSidebarSize,
|
||||
collapsedSidebarSize,
|
||||
isCollapsed,
|
||||
toggleSidebar,
|
||||
};
|
||||
}, [maxSidebarSize, collapsedSidebarSize, isCollapsed, toggleSidebar]);
|
||||
|
||||
return selector ? selector(state) : (state as T);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute, lazyRouteComponent } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId/")({
|
||||
component: lazyRouteComponent(() => import("./-components/preview-page"), "PreviewPage"),
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type React from "react";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import {
|
||||
useInitializeResumeStore,
|
||||
useMergeResumeMetadata,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
} from "@/components/resume/use-resume";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
import {
|
||||
BUILDER_LAYOUT_COOKIE_NAME,
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
useBuilderSidebar,
|
||||
useBuilderSidebarStore,
|
||||
} from "./-store/sidebar";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
|
||||
return { session: context.session };
|
||||
},
|
||||
loader: async ({ params, context }) => {
|
||||
const [layout, resume] = await Promise.all([
|
||||
getBuilderLayoutServerFn(),
|
||||
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
||||
]);
|
||||
|
||||
return { layout, name: resume.name };
|
||||
},
|
||||
head: ({ loaderData }) => ({
|
||||
meta: loaderData ? [{ title: `${loaderData.name} - Reactive Resume` }] : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { layout: initialLayout } = Route.useLoaderData();
|
||||
|
||||
const { resumeId } = Route.useParams();
|
||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
||||
const initializeResumeStore = useInitializeResumeStore();
|
||||
const mergeResumeMetadata = useMergeResumeMetadata();
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
|
||||
useResumeCleanup();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
initializeResumeStore(resume);
|
||||
}, [initializeResumeStore, isInitialized, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
mergeResumeMetadata(resume);
|
||||
}, [
|
||||
mergeResumeMetadata,
|
||||
resume.id,
|
||||
resume.name,
|
||||
resume.slug,
|
||||
resume.tags,
|
||||
resume.isLocked,
|
||||
resume.isPublic,
|
||||
resume.hasPassword,
|
||||
resume,
|
||||
]);
|
||||
|
||||
if (!isInitialized) return null;
|
||||
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
|
||||
maxSidebarSize: state.maxSidebarSize,
|
||||
collapsedSidebarSize: state.collapsedSidebarSize,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(initialLayout);
|
||||
canPersistLayoutRef.current = true;
|
||||
}, [initialLayout, setLayout]);
|
||||
|
||||
const onLayoutChanged = (layout: Layout) => {
|
||||
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
|
||||
if (!canPersistLayoutRef.current) return;
|
||||
setLayout(nextLayout);
|
||||
void setBuilderLayoutServerFn({ data: nextLayout });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
|
||||
setLeftSidebar(leftSidebarRef);
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
|
||||
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
|
||||
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
|
||||
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col">
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarLeft />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-s" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-e" />
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
|
||||
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
|
||||
.handler(async ({ data }) => {
|
||||
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
|
||||
});
|
||||
|
||||
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
|
||||
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return DEFAULT_BUILDER_LAYOUT;
|
||||
return parseBuilderLayoutCookie(layout);
|
||||
});
|
||||
Reference in New Issue
Block a user