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