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

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
@@ -1,12 +1,21 @@
import type { LeftSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Fragment, useCallback, useRef } from "react";
import { toast } from "sonner";
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 { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { getInitials } from "@reactive-resume/utils/string";
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
@@ -50,41 +59,82 @@ function getSectionComponent(type: LeftSidebarSection) {
export function BuilderSidebarLeft() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const isLocked = useIsResumeLocked();
return (
<>
<SidebarEdge scrollAreaRef={scrollAreaRef} />
<SidebarEdge />
<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>
))}
{isLocked && <LockBanner />}
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
{leftSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
<Separator />
</Fragment>
))}
</fieldset>
</div>
</ScrollArea>
</>
);
}
type SidebarEdgeProps = {
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
};
function LockBanner() {
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions());
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
const handleUnlock = () => {
setLocked(
{ id: resume.id, isLocked: false },
{
onSuccess: () => {
patchResume((draft) => {
draft.isLocked = false;
});
},
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
return (
<div className="flex items-center gap-x-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<LockSimpleIcon className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">
<Trans>This resume is locked</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Editing is disabled until you unlock it.</Trans>
</p>
</div>
<Button size="sm" variant="secondary" disabled={isPending} onClick={handleUnlock}>
<Trans>Enable editing</Trans>
</Button>
</div>
);
}
function SidebarEdge() {
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" });
// Section ids are globally unique; document.getElementById reliably resolves the scroll target
// (querying through the ScrollArea ref did not — its ref does not expose the scroll container).
document
.getElementById(`sidebar-${section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
},
[toggleSidebar, scrollAreaRef],
[toggleSidebar],
);
return (
@@ -93,22 +143,30 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
<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>
<Tooltip key={section}>
<TooltipTrigger
render={
<Button
size="icon"
variant="ghost"
aria-label={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
}
/>
<TooltipContent side="right" className="font-medium">
{getSectionTitle(section)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
<UserDropdownMenu>
{({ session }) => (
<Button size="icon" variant="ghost">
<Button size="icon" variant="ghost" aria-label={t`Account menu`}>
<Avatar className="size-6">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="text-[0.5rem]">{getInitials(session.user.name)}</AvatarFallback>
@@ -87,7 +87,7 @@ export const CustomFieldsSection = withForm({
<Popover>
<PopoverTrigger
render={
<Button size="icon" variant="ghost" className="ms-1">
<Button size="icon" variant="ghost" aria-label={t`Add link`} className="ms-1">
<LinkIcon />
</Button>
}
@@ -121,6 +121,7 @@ export const CustomFieldsSection = withForm({
<Button
size="icon"
variant="ghost"
aria-label={t`Remove custom field`}
onClick={() => {
customFieldsField.removeValue(index);
void form.handleSubmit();
@@ -169,6 +170,7 @@ function CustomFieldItem({ field, children }: CustomFieldItemProps) {
<Button
size="icon"
variant="ghost"
aria-label={t`Reorder custom field`}
className="me-2 touch-none"
onPointerDown={(e) => {
e.preventDefault();
@@ -266,7 +266,7 @@ function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps)
return (
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger aria-label={t`Section options`}>
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
@@ -1,13 +1,30 @@
import type { Area } from "react-easy-crop";
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 {
EyeIcon,
EyeSlashIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
TrashSimpleIcon,
UploadSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import Cropper from "react-easy-crop";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
@@ -16,6 +33,8 @@ import {
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Slider } from "@reactive-resume/ui/components/slider";
import "react-easy-crop/react-easy-crop.css";
import { ColorPicker } from "@/components/input/color-picker";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
@@ -109,6 +128,7 @@ function PicturePreviewControls({
<Button
size="icon"
variant="ghost"
aria-label={picture.hidden ? t`Show picture` : t`Hide picture`}
onClick={() => {
form.setFieldValue("hidden", !picture.hidden);
onAutoSave();
@@ -362,6 +382,44 @@ function normalizePictureUrl(url: string, origin: string): string {
}
}
async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.addEventListener("load", () => {
resolve(element);
});
element.addEventListener("error", () => {
reject(new Error("Failed to load image for cropping"));
});
element.src = imageSrc;
});
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context is not available");
canvas.width = Math.round(pixelCrop.width);
canvas.height = Math.round(pixelCrop.height);
context.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
canvas.width,
canvas.height,
);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("Canvas is empty"));
}, "image/png");
});
}
async function createPicturePreviewUrl(url: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
@@ -388,10 +446,20 @@ function usePictureSettingsForm(picture: PictureValues, persist: (data: PictureV
type PictureSettingsForm = ReturnType<typeof usePictureSettingsForm>;
type CropState = {
file: File;
imageSrc: string;
};
function PictureSectionForm() {
const fileInputRef = useRef<HTMLInputElement>(null);
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
const [cropState, setCropState] = useState<CropState | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const resume = useCurrentResume();
const picture = resume.data.picture;
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
@@ -441,10 +509,7 @@ function PictureSectionForm() {
handleAutoSave();
};
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const uploadPictureFile = (file: File) => {
const toastId = toast.loading(t`Uploading picture…`);
uploadFile(file, {
@@ -469,6 +534,41 @@ function PictureSectionForm() {
});
};
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Open the interactive crop step instead of uploading immediately.
setCropState({ file, imageSrc: URL.createObjectURL(file) });
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
};
const closeCropDialog = () => {
if (cropState) URL.revokeObjectURL(cropState.imageSrc);
setCropState(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const onConfirmCrop = async () => {
if (!cropState) return;
let fileToUpload: File = cropState.file;
try {
if (croppedAreaPixels) {
const blob = await getCroppedImageBlob(cropState.imageSrc, croppedAreaPixels);
fileToUpload = new File([blob], cropState.file.name, { type: blob.type });
}
} catch {
// ponytail: canvas crop can fail (tainted image, no context) — fall back to the original file.
fileToUpload = cropState.file;
}
uploadPictureFile(fileToUpload);
closeCropDialog();
};
useEffect(() => {
const objectUrl = picturePreviewQuery.data;
@@ -477,142 +577,223 @@ function PictureSectionForm() {
};
}, [picturePreviewQuery.data]);
const cropAspect = Number(form.state.values.aspectRatio) || 1;
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<PicturePreviewControls
fileInputRef={fileInputRef}
form={form}
normalizedPictureUrl={normalizedPictureUrl}
picture={picture}
pictureSrc={pictureSrc}
onAutoSave={handleAutoSave}
onDeletePicture={onDeletePicture}
onSelectPicture={onSelectPicture}
onUploadPicture={onUploadPicture}
/>
<>
<Dialog
open={cropState !== null}
onOpenChange={(open) => {
if (!open) closeCropDialog();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Crop picture</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Drag to reposition and use the slider to zoom before uploading.</Trans>
</DialogDescription>
</DialogHeader>
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
{cropState && (
<div className="relative h-64 w-full overflow-hidden rounded-md bg-secondary ring-1 ring-border ring-inset">
<Cropper
image={cropState.imageSrc}
crop={crop}
zoom={zoom}
aspect={cropAspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={(_, areaPixels) => {
setCroppedAreaPixels(areaPixels);
}}
/>
</div>
)}
<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>
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<FormLabel className="mb-0">
<Trans>Zoom</Trans>
</FormLabel>
<span className="text-muted-foreground text-xs tabular-nums">{zoom.toFixed(1)}×</span>
</div>
<div className="flex items-center gap-x-3">
<MagnifyingGlassMinusIcon className="size-4 shrink-0 text-muted-foreground" />
<Slider
min={1}
max={3}
step={0.01}
value={[zoom]}
aria-label={t`Zoom`}
className="flex-1"
onValueChange={(value) => {
setZoom(Array.isArray(value) ? value[0] : value);
}}
/>
<MagnifyingGlassPlusIcon className="size-4 shrink-0 text-muted-foreground" />
</div>
</div>
<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>
<DialogFooter>
<Button variant="outline" onClick={closeCropDialog}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={() => {
void onConfirmCrop();
}}
>
<Trans>Save & Upload</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<PicturePreviewControls
fileInputRef={fileInputRef}
form={form}
normalizedPictureUrl={normalizedPictureUrl}
picture={picture}
pictureSrc={pictureSrc}
onAutoSave={handleAutoSave}
onDeletePicture={onDeletePicture}
onSelectPicture={onSelectPicture}
onUploadPicture={onUploadPicture}
/>
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
<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={
<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));
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</div>
</FormItem>
)}
</form.Field>
<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();
}}
<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();
}}
/>
}
/>
}
/>
</FormItem>
)}
</form.Field>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</div>
<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>
<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={
<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));
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</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>
</div>
</form>
</form>
</>
);
}
@@ -1,5 +1,6 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { CaretDownIcon } from "@phosphor-icons/react";
import { getDefaultSectionIconName } from "@reactive-resume/schema/resume/section-icons";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
@@ -36,6 +37,8 @@ export function SectionBase({ type, className, ...props }: Props) {
const fallbackIcon = hasSectionIcon ? getDefaultSectionIconName(type as "summary" | SectionType) : "";
const sectionIcon = rawIcon === "none" ? "" : rawIcon || fallbackIcon;
const sectionTitle = ("title" in section && section.title) || getSectionTitle(type);
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
@@ -64,7 +67,7 @@ export function SectionBase({ type, className, ...props }: Props) {
<AccordionTrigger
className="me-2 items-center justify-center"
render={
<Button size="icon" variant="ghost">
<Button size="icon" variant="ghost" aria-label={t`Toggle ${sectionTitle} section`}>
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
</Button>
}
@@ -76,9 +79,7 @@ export function SectionBase({ type, className, ...props }: Props) {
) : (
getSectionIcon(type)
)}
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">
{("title" in section && section.title) || getSectionTitle(type)}
</h2>
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{sectionTitle}</h2>
</div>
{!["picture", "basics", "custom"].includes(type) && (
@@ -276,7 +276,10 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
</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">
<DropdownMenuTrigger
aria-label={t`Options for ${title}`}
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>
@@ -112,7 +112,7 @@ export function SectionDropdownMenu({ type }: Props) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost">
<Button size="icon" variant="ghost" aria-label={t`Section options`}>
<ListIcon />
</Button>
}
@@ -4,6 +4,7 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { Copyright } from "@/components/ui/copyright";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
@@ -43,7 +44,7 @@ export function BuilderSidebarRight() {
return (
<>
<SidebarEdge scrollAreaRef={scrollAreaRef} />
<SidebarEdge />
<ScrollArea
ref={scrollAreaRef}
@@ -64,22 +65,18 @@ export function BuilderSidebarRight() {
);
}
type SidebarEdgeProps = {
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
};
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
function SidebarEdge() {
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" });
// Section ids are globally unique; document.getElementById reliably resolves the scroll target.
document
.getElementById(`sidebar-${section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
},
[toggleSidebar, scrollAreaRef],
[toggleSidebar],
);
return (
@@ -87,15 +84,23 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
<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>
<Tooltip key={section}>
<TooltipTrigger
render={
<Button
size="icon"
variant="ghost"
aria-label={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
}
/>
<TooltipContent side="left" className="font-medium">
{getSectionTitle(section)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -1,4 +1,5 @@
import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, m } from "motion/react";
@@ -221,6 +222,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
return (
<button
type="button"
aria-label={t`Use color ${color}`}
onClick={() => onSelect(color)}
className={cn(
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
@@ -1,58 +1,13 @@
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 { buildDocx } from "@reactive-resume/docx";
import { Button } from "@reactive-resume/ui/components/button";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { useResume } from "@/features/resume/builder/draft";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
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]);
const resume = useResume();
const { onDownloadJSON, onDownloadDOCX, onDownloadPDF, isExporting } = useResumeExport(resume);
if (!resume) return null;
@@ -94,11 +49,11 @@ export function ExportSectionBuilder() {
<Button
variant="outline"
disabled={isPrinting}
disabled={isExporting}
onClick={onDownloadPDF}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
{isPrinting ? (
{isExporting ? (
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
) : (
<FilePdfIcon className="size-6 shrink-0" />
@@ -1,4 +1,5 @@
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { CSSProperties, HTMLAttributes, Ref } from "react";
import {
closestCorners,
@@ -13,10 +14,29 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
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 {
ArrowBendUpRightIcon,
DotsSixVerticalIcon,
DotsThreeVerticalIcon,
FileIcon,
PlusCircleIcon,
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useCallback, useId, useState } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
@@ -385,24 +405,202 @@ type SortableLayoutItemProps = {
columnId: ColumnId;
};
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
function SortableLayoutItem({ id, pageIndex, columnId }: 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} />
<LayoutItemContent
ref={setNodeRef}
id={id}
pageIndex={pageIndex}
columnId={columnId}
style={style}
isDragging={isDragging}
{...attributes}
{...listeners}
/>
);
}
type MoveToSubmenuProps = {
id: string;
pageIndex: number;
columnId: ColumnId;
};
/**
* "Move to" submenu that mirrors the left-panel item menu but works at the
* section level: it splices the section out of its current page/column and
* pushes it onto the chosen target (or a brand new page).
*/
function MoveToSubmenu({ id, pageIndex, columnId }: MoveToSubmenuProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const pages = resume.data.metadata.layout.pages;
// When the template collapses the sidebar, no page has a usable sidebar column.
const sidebarCollapsed = templates[resume.data.metadata.template].sidebarPosition === "none";
const moveTo = (targetPageIndex: number, targetColumnId: ColumnId) => {
updateResumeData((draft) => {
const from = draft.metadata.layout.pages[pageIndex][columnId];
const index = from.indexOf(id);
if (index === -1) return;
from.splice(index, 1);
draft.metadata.layout.pages[targetPageIndex][targetColumnId].push(id);
});
};
const moveToNewPage = () => {
updateResumeData((draft) => {
const from = draft.metadata.layout.pages[pageIndex][columnId];
const index = from.indexOf(id);
if (index === -1) return;
from.splice(index, 1);
draft.metadata.layout.pages.push({ fullWidth: false, main: [id], sidebar: [] });
});
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowBendUpRightIcon />
<Trans>Move to</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{pages.map((page, targetPageIndex) => {
// Full-width pages hide their sidebar, so never offer it as a target.
const sidebarHidden = sidebarCollapsed || page.fullWidth;
return (
<DropdownMenuSub key={`page-${targetPageIndex}`}>
<DropdownMenuSubTrigger>
<FileIcon />
<Trans>Page {targetPageIndex + 1}</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
disabled={targetPageIndex === pageIndex && columnId === "main"}
onClick={() => moveTo(targetPageIndex, "main")}
>
{getColumnLabel("main")}
</DropdownMenuItem>
{!sidebarHidden && (
<DropdownMenuItem
disabled={targetPageIndex === pageIndex && columnId === "sidebar"}
onClick={() => moveTo(targetPageIndex, "sidebar")}
>
{getColumnLabel("sidebar")}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={moveToNewPage}>
<PlusCircleIcon />
<Trans>New Page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
type SectionBreakField = "keepTogether" | "startOnNewPage";
const readSectionBreak = (data: ResumeData, id: string, field: SectionBreakField): boolean => {
if (id === "summary") return data.summary[field];
if (id in data.sections) return data.sections[id as SectionType][field];
return data.customSections.find((section) => section.id === id)?.[field] ?? false;
};
type SectionBreakItemsProps = {
id: string;
};
/**
* Per-section page-break controls. These write the declarative `keepTogether` /
* `startOnNewPage` flags onto the section metadata (summary, standard, or custom),
* which the PDF renderer applies as `wrap` / `break` on the section container.
*/
function SectionBreakItems({ id }: SectionBreakItemsProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const keepTogether = readSectionBreak(resume.data, id, "keepTogether");
const startOnNewPage = readSectionBreak(resume.data, id, "startOnNewPage");
const toggle = (field: SectionBreakField) => {
updateResumeData((draft) => {
if (id === "summary") {
draft.summary[field] = !draft.summary[field];
return;
}
if (id in draft.sections) {
const section = draft.sections[id as SectionType];
section[field] = !section[field];
return;
}
const custom = draft.customSections.find((section) => section.id === id);
if (custom) custom[field] = !custom[field];
});
};
return (
<>
<DropdownMenuCheckboxItem
checked={keepTogether}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => toggle("keepTogether")}
>
<Trans comment="Layout editor toggle that prevents a section from splitting across pages">Keep together</Trans>
</DropdownMenuCheckboxItem>
<p className="px-2 pb-1 text-muted-foreground text-xs">
<Trans comment="Helper note explaining the keep-together limitation">
Only applies when the section fits on a single page.
</Trans>
</p>
<DropdownMenuCheckboxItem
checked={startOnNewPage}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => toggle("startOnNewPage")}
>
<Trans comment="Layout editor toggle that forces a section to begin on a new page">Start on new page</Trans>
</DropdownMenuCheckboxItem>
</>
);
}
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
id: string;
ref?: Ref<HTMLDivElement>;
pageIndex?: number;
columnId?: ColumnId;
isDragging?: boolean;
isOverlay?: boolean;
};
function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, ...rest }: LayoutItemContentProps) {
function LayoutItemContent({
id,
ref,
pageIndex,
columnId,
isDragging,
isOverlay,
className,
style,
...rest
}: LayoutItemContentProps) {
const resume = useCurrentResume();
const title = resume ? resolveLayoutSectionTitle(resume.data, id) : id;
@@ -422,7 +620,26 @@ function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, .
{...rest}
>
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
<span className="truncate">{title}</span>
<span className="min-w-0 flex-1 truncate">{title}</span>
{/* The drag overlay renders without a location; only real rows get the menu. */}
{!isOverlay && pageIndex !== undefined && columnId !== undefined && (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t`Move section to another column or page`}
onPointerDown={(event) => event.stopPropagation()}
className="flex cursor-context-menu items-center rounded p-0.5 opacity-40 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover/item:opacity-100"
>
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<MoveToSubmenu id={id} pageIndex={pageIndex} columnId={columnId} />
<DropdownMenuSeparator />
<SectionBreakItems id={id} />
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
@@ -139,7 +139,7 @@ export function SharingSectionBuilder() {
<div className="flex items-center gap-x-2">
<Input readOnly id="sharing-url" value={publicUrl} />
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
<Button size="icon" variant="ghost" aria-label={t`Copy URL`} onClick={onCopyUrl}>
<ClipboardIcon />
</Button>
</div>
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { computeDelta, getSparklinePoints } from "./statistics";
describe("computeDelta", () => {
it("returns null when the prior period had no activity", () => {
// Prior 2 days are all zero -> no baseline to compare against.
expect(computeDelta([0, 0, 5, 5], 2)).toBeNull();
});
it("returns a positive percentage when the recent period grew", () => {
// previous sum = 2, recent sum = 4 -> +100%
expect(computeDelta([1, 1, 2, 2], 2)).toBe(100);
});
it("returns a negative percentage when the recent period shrank", () => {
// previous sum = 4, recent sum = 2 -> -50%
expect(computeDelta([2, 2, 1, 1], 2)).toBe(-50);
});
});
describe("getSparklinePoints", () => {
it("returns null for a single point", () => {
expect(getSparklinePoints([5], 80, 24)).toBeNull();
});
it("returns null for an all-zero series", () => {
expect(getSparklinePoints([0, 0, 0], 80, 24)).toBeNull();
});
it("returns points for all-equal non-zero values (flat line at the top)", () => {
// max === value, so every y is 0; x spans 0..width.
expect(getSparklinePoints([3, 3, 3], 80, 24)).toBe("0.0,0.0 40.0,0.0 80.0,0.0");
});
});
@@ -17,18 +17,29 @@ const queryResult = vi.hoisted(() => ({
},
}));
const dailyResult = vi.hoisted(() => ({
data: undefined as undefined | { date: string; views: number; downloads: number }[],
}));
type SectionBaseProps = {
children: React.ReactNode;
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => queryResult,
useQuery: (options: { __key?: string }) => (options.__key === "daily" ? dailyResult : queryResult),
}));
vi.mock("@tanstack/react-router", () => ({
useParams: () => ({ resumeId: "r1" }),
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } },
orpc: {
resume: {
statistics: {
getById: { queryOptions: () => ({ __key: "getById" }) },
getDailyById: { queryOptions: () => ({ __key: "daily" }) },
},
},
},
}));
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
@@ -42,6 +53,7 @@ beforeAll(() => {
beforeEach(() => {
queryResult.data = undefined;
dailyResult.data = undefined;
});
const renderStats = () =>
@@ -84,6 +96,24 @@ describe("StatisticsSectionBuilder", () => {
expect(screen.getByText("Downloads")).toBeInTheDocument();
});
it("renders a prior-period delta from the daily series", () => {
queryResult.data = {
isPublic: true,
views: 30,
downloads: 0,
lastViewedAt: null,
lastDownloadedAt: null,
};
// 60 days: prior 30 sum to 10, recent 30 sum to 20 -> +100%.
dailyResult.data = Array.from({ length: 60 }, (_, i) => ({
date: `2024-01-${String(i + 1).padStart(2, "0")}`,
views: i < 30 ? (i < 10 ? 1 : 0) : i < 50 ? 1 : 0,
downloads: 0,
}));
renderStats();
expect(screen.getByText(/\+100%/)).toBeInTheDocument();
});
it("renders 'last viewed/downloaded' timestamps when present", () => {
queryResult.data = {
isPublic: true,
@@ -5,17 +5,53 @@ 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 { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
const TREND_DAYS = 60;
const WINDOW = 30;
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
export function StatisticsSectionBuilder() {
const params = useParams({ from: "/builder/$resumeId" });
const { data: statistics } = useQuery(
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
);
const { data: daily } = useQuery(
orpc.resume.statistics.getDailyById.queryOptions({
input: { id: params.resumeId, days: TREND_DAYS },
enabled: Boolean(statistics?.isPublic),
}),
);
if (!statistics) return null;
const viewsSeries = daily?.map((day) => day.views) ?? [];
const downloadsSeries = daily?.map((day) => day.downloads) ?? [];
return (
<SectionBase type="statistics">
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
@@ -41,12 +77,14 @@ export function StatisticsSectionBuilder() {
<StatisticsItem
label={t`Views`}
value={statistics.views}
series={viewsSeries}
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
/>
<StatisticsItem
label={t`Downloads`}
value={statistics.downloads}
series={downloadsSeries}
timestamp={
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
}
@@ -61,15 +99,59 @@ export function StatisticsSectionBuilder() {
type StatisticsItemProps = {
label: string;
value: number;
series: number[];
timestamp: string | null;
};
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
function StatisticsItem({ label, value, series, timestamp }: StatisticsItemProps) {
const recent = series.slice(-WINDOW);
const delta = computeDelta(series, WINDOW);
return (
<div>
<h4 className="mb-1 font-mono font-semibold text-4xl">{value}</h4>
<div className="mb-1 flex items-center justify-between gap-2">
<h4 className="font-mono font-semibold text-4xl">{value}</h4>
<Sparkline title={t`${label} over the last 30 days`} values={recent} />
</div>
<p className="font-medium text-muted-foreground leading-none">{label}</p>
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
{delta === null ? (
<span className="text-muted-foreground text-xs">
<Trans>No prior data</Trans>
</span>
) : (
<span className={cn("text-xs", delta >= 0 ? "text-emerald-600 dark:text-emerald-500" : "text-red-600")}>
{`${delta >= 0 ? "+" : ""}${delta}% `}
<Trans>vs previous 30 days</Trans>
</span>
)}
{timestamp && <span className="block text-muted-foreground text-xs">{timestamp}</span>}
</div>
);
}
type SparklineProps = {
title: string;
values: number[];
};
function Sparkline({ title, values }: SparklineProps) {
const width = 80;
const height = 24;
const points = getSparklinePoints(values, width, height);
if (!points) return null;
return (
<svg className="text-primary" height={height} role="img" viewBox={`0 0 ${width} ${height}`} width={width}>
<title>{title}</title>
<polyline
fill="none"
points={points}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
}
@@ -1,12 +1,23 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { SwapIcon } from "@phosphor-icons/react";
import { lazy, Suspense } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
import { templates } from "@/dialogs/resume/template/data";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens, keeping it out
// of the SSR/module graph — mirrors the `ResumePreview` entry convention.
const TemplateLivePreview = lazy(() =>
import("@/features/resume/preview/template-live-preview").then((module) => ({
default: module.TemplateLivePreview,
})),
);
export function TemplateSectionBuilder() {
return (
<SectionBase type="template">
@@ -29,19 +40,43 @@ function TemplateSectionForm() {
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>
<HoverCard>
<HoverCardTrigger
delay={300}
render={
<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="absolute inset-0 flex items-center justify-center">
<SwapIcon size={48} weight="thin" className="size-12" />
</div>
</Button>
}
/>
<HoverCardContent side="right" align="start" className="w-64 p-1.5">
<Suspense
fallback={
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
</div>
}
>
<TemplateLivePreview
data={resume.data}
template={template}
fallbackSrc={metadata.imageUrl}
alt={t`Live preview of your resume in the ${metadata.name} template`}
/>
</Suspense>
</HoverCardContent>
</HoverCard>
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
<div className="space-y-1">
@@ -1,4 +1,5 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
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";
@@ -13,6 +14,7 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
export function SectionBase({ type, className, ...props }: Props) {
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
const sectionTitle = getSectionTitle(type);
return (
<Accordion
@@ -26,7 +28,7 @@ export function SectionBase({ type, className, ...props }: Props) {
<AccordionTrigger
className="me-2 items-center justify-center"
render={
<Button size="icon" variant="ghost">
<Button size="icon" variant="ghost" aria-label={t`Toggle ${sectionTitle} section`}>
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
</Button>
}
@@ -34,7 +36,7 @@ export function SectionBase({ type, className, ...props }: Props) {
<div className="flex flex-1 items-center gap-x-4">
{getSectionIcon(type)}
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{getSectionTitle(type)}</h2>
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{sectionTitle}</h2>
</div>
</div>