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:
Amruth Pillai
2026-09-29 18:13:12 +02:00
parent 7304c38303
commit 39f30ac9a1
7 changed files with 356 additions and 318 deletions
@@ -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 = () => {
+52 -52
View File
@@ -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`}