mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
fix(web): name the color picker and keep a typed address while it saves
The color picker trigger receives the id and label FormControl gives it, and the address field no longer overwrites what you are typing when its own auto-save lands. Picture settings, the share tabs and the public page's copy button are split into shared parts and compile with the React Compiler.
This commit is contained in:
@@ -27,7 +27,10 @@ const presetColors = [
|
||||
"rgba(69, 85, 108, 1)",
|
||||
] as const;
|
||||
|
||||
type ColorPickerProps = {
|
||||
type ColorPickerProps = Omit<
|
||||
React.ComponentProps<typeof PopoverTrigger>,
|
||||
"children" | "defaultValue" | "onChange" | "value"
|
||||
> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -47,6 +50,7 @@ export function ColorPicker({
|
||||
onOpenChangeComplete,
|
||||
trigger,
|
||||
children,
|
||||
...triggerProps
|
||||
}: ColorPickerProps) {
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
@@ -64,7 +68,7 @@ export function ColorPicker({
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}>
|
||||
{trigger ?? (
|
||||
<PopoverTrigger>
|
||||
<PopoverTrigger aria-label={t`Choose color`} {...triggerProps}>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-ink/60 transition-transform duration-quick ease-enter focus-visible:outline-hidden active:scale-[0.97]"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
|
||||
@@ -487,17 +487,209 @@ type CropState = {
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
type PictureCropDialogProps = {
|
||||
cropState: CropState | null;
|
||||
aspect: number;
|
||||
onClose: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
};
|
||||
|
||||
function PictureCropDialog({ cropState, aspect, onClose, onUpload }: PictureCropDialogProps) {
|
||||
// Closing keeps the picture in the cropper until the dialog has faded out.
|
||||
const [shownCrop, onCropOpenChangeComplete] = useClosingValue(cropState);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const onConfirmCrop = async () => {
|
||||
if (!cropState) return;
|
||||
|
||||
let fileToUpload: File = cropState.file;
|
||||
try {
|
||||
if (croppedAreaPixels) {
|
||||
const blob = await getCroppedImageBlob(cropState.imageSrc, croppedAreaPixels, cropState.file.type);
|
||||
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;
|
||||
}
|
||||
|
||||
onUpload(fileToUpload);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={cropState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) {
|
||||
// The object URL is freed only once the cropper has gone; the next picture starts unzoomed.
|
||||
if (shownCrop) URL.revokeObjectURL(shownCrop.imageSrc);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
onCropOpenChangeComplete(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Crop picture</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Drag to reposition and use the slider to zoom before uploading.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{shownCrop && (
|
||||
<div className="relative h-64 w-full overflow-hidden rounded-md bg-sunken ring-1 ring-line ring-inset">
|
||||
<Cropper
|
||||
image={shownCrop.imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, areaPixels) => {
|
||||
setCroppedAreaPixels(areaPixels);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="mb-0">
|
||||
<Trans>Zoom</Trans>
|
||||
</FormLabel>
|
||||
<span className="text-ink-3 text-xs tabular-nums">{zoom.toFixed(1)}×</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Icon name="zoom_out" size={16} className="shrink-0 text-ink-3" />
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<Icon name="zoom_in" size={16} className="shrink-0 text-ink-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row flex-wrap justify-between">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!cropState) return;
|
||||
onUpload(cropState.file);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Trans>Skip and Upload</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void onConfirmCrop();
|
||||
}}
|
||||
>
|
||||
<Trans>Crop and Upload</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorWidthFieldsProps = PictureFieldProps & {
|
||||
colorName: "borderColor" | "shadowColor";
|
||||
widthName: "borderWidth" | "shadowWidth";
|
||||
colorLabel: string;
|
||||
widthLabel: string;
|
||||
step: number;
|
||||
};
|
||||
|
||||
function ColorWidthFields(props: ColorWidthFieldsProps) {
|
||||
const { form, onAutoSave, colorName, widthName, colorLabel, widthLabel, step } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name={colorName}>
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel className="sr-only">{colorLabel}</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
onAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name={widthName}>
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>{widthLabel}</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={step}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
onAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every photo option: upload with crop, address, show or hide, delete, fit, size, rotation, shape, border and shadow. */
|
||||
export function PictureSettings() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const appOrigin = window.location.origin;
|
||||
|
||||
const [cropState, setCropState] = useState<CropState | null>(null);
|
||||
// Closing keeps the picture in the cropper until the dialog has faded out.
|
||||
const [shownCrop, onCropOpenChangeComplete] = useClosingValue(cropState);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
@@ -578,9 +770,6 @@ export function PictureSettings() {
|
||||
|
||||
// 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 = () => {
|
||||
@@ -588,115 +777,14 @@ export function PictureSettings() {
|
||||
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, cropState.file.type);
|
||||
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();
|
||||
};
|
||||
|
||||
const cropAspect = Number(form.state.values.aspectRatio) || 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={cropState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeCropDialog();
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
// The object URL is freed only once the cropper has gone.
|
||||
if (!open && shownCrop) URL.revokeObjectURL(shownCrop.imageSrc);
|
||||
onCropOpenChangeComplete(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Crop picture</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Drag to reposition and use the slider to zoom before uploading.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{shownCrop && (
|
||||
<div className="relative h-64 w-full overflow-hidden rounded-md bg-sunken ring-1 ring-line ring-inset">
|
||||
<Cropper
|
||||
image={shownCrop.imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={cropAspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, areaPixels) => {
|
||||
setCroppedAreaPixels(areaPixels);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="mb-0">
|
||||
<Trans>Zoom</Trans>
|
||||
</FormLabel>
|
||||
<span className="text-ink-3 text-xs tabular-nums">{zoom.toFixed(1)}×</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Icon name="zoom_out" size={16} className="shrink-0 text-ink-3" />
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<Icon name="zoom_in" size={16} className="shrink-0 text-ink-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row flex-wrap justify-between">
|
||||
<Button variant="secondary" onClick={closeCropDialog}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!cropState) return;
|
||||
uploadPictureFile(cropState.file);
|
||||
closeCropDialog();
|
||||
}}
|
||||
>
|
||||
<Trans>Skip and Upload</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void onConfirmCrop();
|
||||
}}
|
||||
>
|
||||
<Trans>Crop and Upload</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PictureCropDialog
|
||||
cropState={cropState}
|
||||
aspect={Number(form.state.values.aspectRatio) || 1}
|
||||
onClose={closeCropDialog}
|
||||
onUpload={uploadPictureFile}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -722,123 +810,24 @@ export function PictureSettings() {
|
||||
<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={
|
||||
<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>
|
||||
<ColorWidthFields
|
||||
form={form}
|
||||
colorName="borderColor"
|
||||
widthName="borderWidth"
|
||||
colorLabel={t`Border Color`}
|
||||
widthLabel={t`Border Width`}
|
||||
step={1}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
<ColorWidthFields
|
||||
form={form}
|
||||
colorName="shadowColor"
|
||||
widthName="shadowWidth"
|
||||
colorLabel={t`Shadow Color`}
|
||||
widthLabel={t`Shadow Width`}
|
||||
step={0.5}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -3,15 +3,15 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Swap } from "@reactive-resume/ui/components/swap";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { CopyLinkButton } from "@/features/resume/share/copy-link-button";
|
||||
import { POP_CLASS } from "@/libs/motion";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { PdfViewer } from "./pdf-viewer";
|
||||
@@ -46,7 +46,6 @@ export function PublicResumePage({ resume, username, slug, flags, isRoot = false
|
||||
...(resume ? { publicResumePdf: { publicResume } } : {}),
|
||||
});
|
||||
const phone = useBreakpoint() === "mobile";
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
if (!resume) return <LoadingScreen />;
|
||||
@@ -55,16 +54,11 @@ export function PublicResumePage({ resume, username, slug, flags, isRoot = false
|
||||
const downloads = resume.showDownloadButtons !== false;
|
||||
const subtitle = [basics.headline, basics.location].filter(Boolean).join(" · ");
|
||||
|
||||
const copyLink = async () => {
|
||||
await copy(window.location.href);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const share = async () => {
|
||||
const title = basics.name || resume.name;
|
||||
if (typeof navigator.share === "function") {
|
||||
try {
|
||||
await navigator.share({ title: basics.name || resume.name, url: window.location.href });
|
||||
await navigator.share({ title, url: window.location.href });
|
||||
} catch {
|
||||
// Closing the share sheet isn't an error.
|
||||
}
|
||||
@@ -135,23 +129,7 @@ export function PublicResumePage({ resume, username, slug, flags, isRoot = false
|
||||
<h1 className="truncate font-display font-medium text-xl leading-6">{basics.name || resume.name}</h1>
|
||||
{subtitle && <p className="truncate text-ink-3 text-xs">{subtitle}</p>}
|
||||
</div>
|
||||
<Button variant="secondary" aria-live="polite" onClick={() => void copyLink()}>
|
||||
<Swap
|
||||
swapped={copied}
|
||||
from={
|
||||
<>
|
||||
<Icon name="link" size={18} />
|
||||
<Trans>Copy link</Trans>
|
||||
</>
|
||||
}
|
||||
to={
|
||||
<>
|
||||
<Icon name="check" size={18} />
|
||||
<Trans>Copied</Trans>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<CopyLinkButton url={window.location.href} label={t`Copy link`} icon="link" />
|
||||
{downloads && download}
|
||||
</header>
|
||||
<main id="main-content" className="flex flex-1 justify-center px-6 pt-8 pb-5 print:block print:p-0">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IconName } from "@reactive-resume/ui/components/icon";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useRef, useState } from "react";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Swap } from "@reactive-resume/ui/components/swap";
|
||||
|
||||
const COPIED_MS = 2000;
|
||||
|
||||
type CopyLinkButtonProps = {
|
||||
url: string;
|
||||
/** The idle text, already translated: "Copy" or "Copy link". */
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onCopied?: () => void;
|
||||
};
|
||||
|
||||
/** Copies `url` and shows "Copied" for two seconds. */
|
||||
export function CopyLinkButton({
|
||||
url,
|
||||
label,
|
||||
icon = "content_copy",
|
||||
disabled,
|
||||
className,
|
||||
onCopied,
|
||||
}: CopyLinkButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const timer = useRef<number>(undefined);
|
||||
|
||||
const copy = async () => {
|
||||
await copyToClipboard(url);
|
||||
setCopied(true);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
onCopied?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
aria-live="polite"
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
<Swap
|
||||
swapped={copied}
|
||||
from={
|
||||
<>
|
||||
<Icon name={icon} size={18} />
|
||||
{label}
|
||||
</>
|
||||
}
|
||||
to={
|
||||
<>
|
||||
<Icon name="check" size={18} />
|
||||
<Trans>Copied</Trans>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -234,13 +234,14 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
|
||||
|
||||
const download = async (as: ExportFormat) => {
|
||||
const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf";
|
||||
const file = `${sanitizeFileName(name) || getDefaultFileName(resume)}${extension}`;
|
||||
const letterId = withLetter ? linkedLetter?.id : undefined;
|
||||
setState("busy");
|
||||
try {
|
||||
const blob = await createExportFile(resume, as);
|
||||
const file = `${sanitizeFileName(name) || getDefaultFileName(resume)}${extension}`;
|
||||
downloadWithAnchor(blob, file);
|
||||
if (withLetter && linkedLetter) {
|
||||
const letter = await client.coverLetters.getById({ id: linkedLetter.id });
|
||||
if (letterId) {
|
||||
const letter = await client.coverLetters.getById({ id: letterId });
|
||||
downloadWithAnchor(await createLetterFile(letter, words, as), `${letterFileName(letter, words)}${extension}`);
|
||||
toast.add({ description: t`Downloaded ${file} and the cover letter` });
|
||||
} else {
|
||||
|
||||
@@ -112,9 +112,8 @@ export function HistoryTimeline({ source }: { source: HistorySource }) {
|
||||
await action();
|
||||
} catch (error) {
|
||||
toast.add({ type: "error", description: source.errorMessage(error) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
|
||||
@@ -5,12 +5,11 @@ import { ORPCError } from "@orpc/client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useCopyToClipboard, useDebounceValue } from "usehooks-ts";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Swap } from "@reactive-resume/ui/components/swap";
|
||||
import { SwitchRow } from "@reactive-resume/ui/components/switch";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
@@ -20,10 +19,10 @@ import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { ENTER_CLASS } from "@/libs/motion";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { CopyLinkButton } from "./copy-link-button";
|
||||
import { formatTimeSince, summarizeViews } from "./format";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const COPIED_MS = 2000;
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
@@ -41,12 +40,13 @@ export function LinkTab() {
|
||||
const url = `${window.location.origin}/${session?.user.username ?? ""}/${resume.slug}`;
|
||||
|
||||
const setPublic = async (checked: boolean) => {
|
||||
const description = checked ? t`Link is live` : t`Link turned off. The address is kept`;
|
||||
try {
|
||||
const updated = await updateResume({ id: resume.id, isPublic: checked });
|
||||
patchResume((draft) => {
|
||||
draft.isPublic = updated.isPublic;
|
||||
});
|
||||
toast.add({ description: checked ? t`Link is live` : t`Link turned off. The address is kept` });
|
||||
toast.add({ description });
|
||||
} catch (error) {
|
||||
toast.add({ type: "error", description: errorMessage(error) });
|
||||
}
|
||||
@@ -113,24 +113,19 @@ export function LinkTab() {
|
||||
|
||||
type AddressFieldProps = { url: string; username: string };
|
||||
|
||||
/** The address, checked as you type (300 ms). The old one stays live until the new one is valid and saved. */
|
||||
function AddressField({ url, username }: AddressFieldProps) {
|
||||
/** The address as typed, its live availability check (300 ms), and the save once a new one checks out. */
|
||||
function useSlugAvailability() {
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
const id = useId();
|
||||
const isPublic = resume.isPublic ?? false;
|
||||
const [slug, setSlug] = useState(resume.slug);
|
||||
const [debouncedSlug] = useDebounceValue(slug, 300);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copiedTimer = useRef<number>(undefined);
|
||||
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
|
||||
|
||||
const changed = slug !== resume.slug;
|
||||
const wellFormed = SLUG_PATTERN.test(slug);
|
||||
const check = useQuery({
|
||||
...orpc.resume.checkSlug.queryOptions({ input: { resumeId: resume.id, slug: debouncedSlug } }),
|
||||
enabled: isPublic && changed && wellFormed && debouncedSlug === slug,
|
||||
enabled: (resume.isPublic ?? false) && changed && wellFormed && debouncedSlug === slug,
|
||||
});
|
||||
const result = debouncedSlug === slug ? check.data : undefined;
|
||||
|
||||
@@ -151,31 +146,46 @@ function AddressField({ url, username }: AddressFieldProps) {
|
||||
});
|
||||
}, [result?.status, slug, resume.id, updateResume, patchResume]);
|
||||
|
||||
// A save here, another tab or a restore can change the address; the field follows it.
|
||||
useEffect(() => setSlug(resume.slug), [resume.slug]);
|
||||
// A save here, another tab or a restore can change the address; the field follows it unless the user is typing.
|
||||
const [savedSlug, setSavedSlug] = useState(resume.slug);
|
||||
if (resume.slug !== savedSlug) {
|
||||
setSavedSlug(resume.slug);
|
||||
if (slug === savedSlug) setSlug(resume.slug);
|
||||
}
|
||||
|
||||
return { slug, setSlug, changed, wellFormed, result };
|
||||
}
|
||||
|
||||
type AddressMessageArgs = {
|
||||
isPublic: boolean;
|
||||
slug: string;
|
||||
wellFormed: boolean;
|
||||
changed: boolean;
|
||||
status: string | undefined;
|
||||
url: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
/** The hint under the field; null when the taken-address block renders its own text. */
|
||||
function addressMessage({ isPublic, slug, wellFormed, changed, status, url, username }: AddressMessageArgs) {
|
||||
if (!isPublic) return t`Turn on the link to choose an address.`;
|
||||
if (!slug) return t`Add an address.`;
|
||||
if (!wellFormed) return t`Use lowercase letters, numbers and single dashes.`;
|
||||
if (!changed) return t`Live at ${url.replace(/^https?:\/\//, "")}`;
|
||||
if (status === "taken") return null;
|
||||
if (status === "available") return t`Available · ${window.location.host}/${username}/${slug}`;
|
||||
return t`Checking…`;
|
||||
}
|
||||
|
||||
/** The address, checked as you type. The old one stays live until the new one is valid and saved. */
|
||||
function AddressField({ url, username }: AddressFieldProps) {
|
||||
const resume = useCurrentResume();
|
||||
const id = useId();
|
||||
const isPublic = resume.isPublic ?? false;
|
||||
const { slug, setSlug, changed, wellFormed, result } = useSlugAvailability();
|
||||
|
||||
const invalid = isPublic && (!slug || !wellFormed || result?.status === "taken");
|
||||
const message = !isPublic
|
||||
? t`Turn on the link to choose an address.`
|
||||
: !slug
|
||||
? t`Add an address.`
|
||||
: !wellFormed
|
||||
? t`Use lowercase letters, numbers and single dashes.`
|
||||
: !changed
|
||||
? t`Live at ${url.replace(/^https?:\/\//, "")}`
|
||||
: result?.status === "taken"
|
||||
? null
|
||||
: result?.status === "available"
|
||||
? t`Available · ${window.location.host}/${username}/${slug}`
|
||||
: t`Checking…`;
|
||||
|
||||
const copy = async () => {
|
||||
await copyToClipboard(url);
|
||||
setCopied(true);
|
||||
window.clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
toast.add({ description: t`Link copied` });
|
||||
};
|
||||
const message = addressMessage({ isPublic, slug, wellFormed, changed, status: result?.status, url, username });
|
||||
|
||||
const canShare = typeof navigator.share === "function";
|
||||
|
||||
@@ -213,23 +223,13 @@ function AddressField({ url, username }: AddressFieldProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="secondary" className="h-[38px] gap-1.5" disabled={!isPublic || changed} onClick={copy}>
|
||||
<Swap
|
||||
swapped={copied}
|
||||
from={
|
||||
<>
|
||||
<Icon name="content_copy" size={18} />
|
||||
<Trans>Copy</Trans>
|
||||
</>
|
||||
}
|
||||
to={
|
||||
<>
|
||||
<Icon name="check" size={18} />
|
||||
<Trans>Copied</Trans>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<CopyLinkButton
|
||||
url={url}
|
||||
label={t`Copy`}
|
||||
className="h-[38px] gap-1.5"
|
||||
disabled={!isPublic || changed}
|
||||
onCopied={() => toast.add({ description: t`Link copied` })}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id={`${id}-message`}
|
||||
|
||||
Reference in New Issue
Block a user