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` })} + />