From 39f30ac9a19edb93b1f8588742ea7fe6590b7e03 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 18:13:12 +0200 Subject: [PATCH] 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. --- .../web/src/components/input/color-picker.tsx | 8 +- .../resume/editor/write/picture-settings.tsx | 453 +++++++++--------- .../features/resume/public/public-resume.tsx | 32 +- .../resume/share/copy-link-button.tsx | 67 +++ .../features/resume/share/download-tab.tsx | 7 +- .../src/features/resume/share/history-tab.tsx | 3 +- .../src/features/resume/share/link-tab.tsx | 104 ++-- 7 files changed, 356 insertions(+), 318 deletions(-) create mode 100644 apps/web/src/features/resume/share/copy-link-button.tsx diff --git a/apps/web/src/components/input/color-picker.tsx b/apps/web/src/components/input/color-picker.tsx index c01da41d8..b54774a8e 100644 --- a/apps/web/src/components/input/color-picker.tsx +++ b/apps/web/src/components/input/color-picker.tsx @@ -27,7 +27,10 @@ const presetColors = [ "rgba(69, 85, 108, 1)", ] as const; -type ColorPickerProps = { +type ColorPickerProps = Omit< + React.ComponentProps, + "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({ value, @@ -64,7 +68,7 @@ export function ColorPicker({ return ( {trigger ?? ( - +
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(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 ( + { + 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); + }} + > + + + + Crop picture + + + Drag to reposition and use the slider to zoom before uploading. + + + + {shownCrop && ( +
+ { + setCroppedAreaPixels(areaPixels); + }} + /> +
+ )} + +
+
+ + Zoom + + {zoom.toFixed(1)}× +
+
+ + { + setZoom(Array.isArray(value) ? value[0] : value); + }} + /> + +
+
+ + + +
+ + +
+
+
+
+ ); +} + +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 ( +
+ + {(field) => ( + 0} + > + {colorLabel} + { + field.handleChange(color); + onAutoSave(); + }} + /> + } + /> + + )} + + + + {(field) => ( + 0}> + {widthLabel} + + { + const value = e.target.value; + if (value === "") field.handleChange("" as unknown as number); + else field.handleChange(Number(value)); + onAutoSave(); + }} + /> + } + /> + + pt + + + + )} + +
+ ); +} + /** Every photo option: upload with crop, address, show or hide, delete, fit, size, rotation, shape, border and shadow. */ export function PictureSettings() { const fileInputRef = useRef(null); const appOrigin = window.location.origin; const [cropState, setCropState] = useState(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(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 ( <> - { - 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); - }} - > - - - - Crop picture - - - Drag to reposition and use the slider to zoom before uploading. - - - - {shownCrop && ( -
- { - setCroppedAreaPixels(areaPixels); - }} - /> -
- )} - -
-
- - Zoom - - {zoom.toFixed(1)}× -
-
- - { - setZoom(Array.isArray(value) ? value[0] : value); - }} - /> - -
-
- - - -
- - -
-
-
-
+
-
- - {(field) => ( - 0} - > - { - field.handleChange(color); - handleAutoSave(); - }} - /> - } - /> - - )} - - - - {(field) => ( - 0} - > - - Border Width - - - { - const value = e.target.value; - if (value === "") field.handleChange("" as unknown as number); - else field.handleChange(Number(value)); - handleAutoSave(); - }} - /> - } - /> - - pt - - - - )} - -
- -
- - {(field) => ( - 0} - > - { - field.handleChange(color); - handleAutoSave(); - }} - /> - } - /> - - )} - - - - {(field) => ( - 0} - > - - Shadow Width - - - { - const value = e.target.value; - if (value === "") field.handleChange("" as unknown as number); - else field.handleChange(Number(value)); - handleAutoSave(); - }} - /> - } - /> - - pt - - - - )} - -
+ +
diff --git a/apps/web/src/features/resume/public/public-resume.tsx b/apps/web/src/features/resume/public/public-resume.tsx index b6329690d..7957355ed 100644 --- a/apps/web/src/features/resume/public/public-resume.tsx +++ b/apps/web/src/features/resume/public/public-resume.tsx @@ -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 ; @@ -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

{basics.name || resume.name}

{subtitle &&

{subtitle}

} - + {downloads && download}
diff --git a/apps/web/src/features/resume/share/copy-link-button.tsx b/apps/web/src/features/resume/share/copy-link-button.tsx new file mode 100644 index 000000000..fd6f21633 --- /dev/null +++ b/apps/web/src/features/resume/share/copy-link-button.tsx @@ -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(undefined); + + const copy = async () => { + await copyToClipboard(url); + setCopied(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); + onCopied?.(); + }; + + return ( + + ); +} diff --git a/apps/web/src/features/resume/share/download-tab.tsx b/apps/web/src/features/resume/share/download-tab.tsx index 8d9cc6206..28f7f0480 100644 --- a/apps/web/src/features/resume/share/download-tab.tsx +++ b/apps/web/src/features/resume/share/download-tab.tsx @@ -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 { diff --git a/apps/web/src/features/resume/share/history-tab.tsx b/apps/web/src/features/resume/share/history-tab.tsx index 48b82013a..c2f0843eb 100644 --- a/apps/web/src/features/resume/share/history-tab.tsx +++ b/apps/web/src/features/resume/share/history-tab.tsx @@ -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 = () => { diff --git a/apps/web/src/features/resume/share/link-tab.tsx b/apps/web/src/features/resume/share/link-tab.tsx index e8ed03cc4..fe74ffb28 100644 --- a/apps/web/src/features/resume/share/link-tab.tsx +++ b/apps/web/src/features/resume/share/link-tab.tsx @@ -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(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) { /> )} - + toast.add({ description: t`Link copied` })} + />