* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
@@ -0,0 +1,397 @@
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Input } from "@reactive-resume/ui/components/input";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { cn } from "@reactive-resume/utils/style";
import { useControlledState } from "@/hooks/use-controlled-state";
const RETURN_KEY = "Enter";
const COMMA_KEY = ",";
type ChipItemProps = {
id: string;
chip: string;
index: number;
isEditing: boolean;
onEdit: (index: number) => void;
onRemove: (index: number) => void;
};
function ChipDragPreview({ chip }: { chip: string }) {
return (
<Badge
variant="outline"
className="h-6 max-w-44 cursor-grabbing select-none justify-start rounded-md border-ring bg-muted px-2 font-medium text-foreground text-xs shadow-lg ring-2 ring-ring/25 sm:max-w-52"
>
<span className="truncate">{chip}</span>
</Badge>
);
}
function ChipDragOverlay({ activeChip }: { activeChip: string | null }) {
const overlay = (
<DragOverlay dropAnimation={null}>{activeChip ? <ChipDragPreview chip={activeChip} /> : null}</DragOverlay>
);
if (typeof document === "undefined") return overlay;
return createPortal(overlay, document.body);
}
function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transition,
zIndex: isDragging ? 10 : undefined,
transform: CSS.Transform.toString(transform),
};
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.92, y: -4 }}
animate={{ opacity: isDragging ? 0.62 : 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: -4 }}
transition={{ duration: 0.1, ease: "easeOut" }}
style={style}
ref={setNodeRef}
className="group/chip relative touch-none"
{...attributes}
{...listeners}
>
<Badge
variant="outline"
className={cn(
"h-6 max-w-full cursor-grab select-none justify-start gap-0 rounded-md border-border bg-muted/55 px-2 font-medium text-foreground text-xs transition-colors hover:border-foreground/20 hover:bg-muted active:cursor-grabbing",
isEditing && "border-primary bg-primary/10 ring-1 ring-primary/40",
isDragging && "border-ring bg-muted shadow-sm",
)}
>
<span className="max-w-32 truncate sm:max-w-44">{chip}</span>
<motion.div
initial={false}
animate={isEditing ? { opacity: 1 } : { opacity: 0.66 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="ms-1.5 flex shrink-0 items-center gap-x-0.5 will-change-[opacity] group-focus-within/chip:opacity-100 group-hover/chip:opacity-100"
>
<button
type="button"
tabIndex={-1}
className="rounded-sm p-0.5 text-foreground/70 transition-colors hover:bg-secondary hover:text-foreground focus:outline-none"
aria-label={t({
comment:
"Screen reader label for button that edits a keyword chip. Variable is the current keyword text.",
message: `Edit ${chip}`,
})}
onClick={(e) => {
e.stopPropagation();
onEdit(index);
}}
>
<PencilSimpleIcon className="size-3.5" />
</button>
<button
type="button"
tabIndex={-1}
className="rounded-sm p-0.5 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus:outline-none"
aria-label={t({
comment:
"Screen reader label for button that removes a keyword chip. Variable is the current keyword text.",
message: `Remove ${chip}`,
})}
onClick={(e) => {
e.stopPropagation();
onRemove(index);
}}
>
<XIcon className="size-3.5" />
</button>
</motion.div>
</Badge>
</motion.div>
);
}
type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
value?: string[];
defaultValue?: string[];
onChange?: (value: string[]) => void;
hideDescription?: boolean;
};
export function ChipInput({ value, defaultValue = [], onChange, className, hideDescription = false, ...props }: Props) {
const [chips, setChips] = useControlledState<string[]>({
value,
defaultValue,
onChange,
});
const [input, setInput] = React.useState("");
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
const [activeChip, setActiveChip] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const dndContextId = React.useId();
const isEditingKeyword = editingIndex !== null;
const hasChips = chips.length > 0;
const addChips = React.useCallback(
(values: string[]) => {
const nextValues = values.map((chip) => chip.trim()).filter(Boolean);
if (nextValues.length === 0) return;
const newChips = Array.from(new Set([...chips, ...nextValues]));
setChips(newChips);
},
[chips, setChips],
);
const addChip = React.useCallback(
(chip: string) => {
addChips([chip]);
},
[addChips],
);
const updateChip = React.useCallback(
(index: number, newValue: string) => {
const trimmed = newValue.trim();
if (!trimmed || index < 0 || index >= chips.length) return;
const existingIndex = chips.findIndex((c, i) => c === trimmed && i !== index);
if (existingIndex !== -1) return;
const newChips = [...chips];
newChips[index] = trimmed;
setChips(newChips);
},
[chips, setChips],
);
const removeChip = React.useCallback(
(index: number) => {
if (index < 0 || index >= chips.length) return;
const newChips = chips.slice(0, index).concat(chips.slice(index + 1));
setChips(newChips);
if (editingIndex === index) {
setEditingIndex(null);
setInput("");
} else if (editingIndex !== null && editingIndex > index) {
setEditingIndex(editingIndex - 1);
}
},
[chips, setChips, editingIndex],
);
const handleEdit = React.useCallback(
(index: number) => {
setEditingIndex(index);
setInput(chips[index]);
inputRef.current?.focus();
},
[chips],
);
const handleReorder = React.useCallback(
(newOrder: string[]) => {
if (editingIndex !== null) {
const editingChip = chips[editingIndex];
const newIndex = newOrder.indexOf(editingChip);
if (newIndex !== -1 && newIndex !== editingIndex) {
setEditingIndex(newIndex);
}
}
setChips(newOrder);
},
[chips, editingIndex, setChips],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 3 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = React.useCallback((event: DragStartEvent) => {
setActiveChip(event.active.id as string);
}, []);
const handleDragCancel = React.useCallback(() => {
setActiveChip(null);
}, []);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
setActiveChip(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = chips.indexOf(active.id as string);
const newIndex = chips.indexOf(over.id as string);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const newOrder = Array.from(chips);
const [removed] = newOrder.splice(oldIndex, 1);
newOrder.splice(newIndex, 0, removed);
handleReorder(newOrder);
}
},
[chips, handleReorder],
);
const handleInputChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (editingIndex !== null) {
if (newValue.includes(",")) {
updateChip(editingIndex, newValue.replace(",", ""));
setEditingIndex(null);
setInput("");
} else {
setInput(newValue);
}
return;
}
if (newValue.includes(",")) {
const parts = newValue.split(",");
addChips(parts.slice(0, -1));
setInput(parts[parts.length - 1]);
} else {
setInput(newValue);
}
},
[addChips, editingIndex, updateChip],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (editingIndex !== null) {
if (input.trim()) {
updateChip(editingIndex, input);
}
setEditingIndex(null);
setInput("");
} else if (input.trim()) {
addChip(input);
setInput("");
}
} else if (e.key === "Escape" && editingIndex !== null) {
setEditingIndex(null);
setInput("");
}
},
[input, addChip, editingIndex, updateChip],
);
return (
<div className={cn("space-y-1.5", className)} {...props}>
<DndContext
id={dndContextId}
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div
role="none"
onClick={() => inputRef.current?.focus()}
className="overflow-hidden rounded-lg border border-input bg-background/40 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/20"
>
<div className="flex flex-col">
<div
className={cn("max-h-24 overflow-y-auto px-2 py-1.5", hasChips ? "border-border/70 border-b" : "hidden")}
>
<SortableContext items={chips} strategy={rectSortingStrategy}>
<motion.div layout className="flex flex-wrap gap-1">
<AnimatePresence initial={false} mode="popLayout">
{chips.map((chip, idx) => (
<ChipItem
key={chip}
id={chip}
chip={chip}
index={idx}
isEditing={editingIndex === idx}
onEdit={handleEdit}
onRemove={removeChip}
/>
))}
</AnimatePresence>
</motion.div>
</SortableContext>
</div>
<div className={cn("flex items-center gap-1.5 px-2", hasChips ? "py-1.5" : "py-0")}>
<Input
ref={inputRef}
type="text"
value={input}
autoComplete="off"
aria-label={isEditingKeyword ? t`Edit keyword` : t`Add keyword`}
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
className="h-9 flex-1 border-none px-0 py-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
/>
<AnimatePresence>
{chips.length > 0 && (
<motion.span
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isEditingKeyword ? 1 : 0.8,
scale: 1,
}}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className={cn(
"flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border px-1.5 font-medium text-[0.7rem] tabular-nums",
isEditingKeyword
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-foreground/80",
)}
>
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
</motion.span>
)}
</AnimatePresence>
</div>
</div>
</div>
<ChipDragOverlay activeChip={activeChip} />
</DndContext>
{!hideDescription && (
<p className="text-muted-foreground text-xs">
<Trans>
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
</Trans>
</p>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import type { ColorResult } from "@uiw/color-convert";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import ReactColorColorful from "@uiw/react-color-colorful";
import { useMemo } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Separator } from "@reactive-resume/ui/components/separator";
import { cn } from "@reactive-resume/utils/style";
import { useControlledState } from "@/hooks/use-controlled-state";
const presetColors = [
"rgba(0, 0, 0, 1)",
"rgba(231, 0, 11, 1)",
"rgba(245, 73, 0, 1)",
"rgba(225, 113, 0, 1)",
"rgba(208, 135, 0, 1)",
"rgba(94, 165, 0, 1)",
"rgba(0, 166, 62, 1)",
"rgba(0, 153, 102, 1)",
"rgba(0, 146, 184, 1)",
"rgba(0, 132, 209, 1)",
"rgba(21, 93, 252, 1)",
"rgba(79, 57, 246, 1)",
"rgba(127, 34, 254, 1)",
"rgba(200, 0, 222, 1)",
"rgba(230, 0, 118, 1)",
"rgba(69, 85, 108, 1)",
] as const;
type ColorPickerProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
trigger?: React.ReactNode;
children?: React.ReactNode;
};
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
const [currentValue, setCurrentValue] = useControlledState<string>({
value,
defaultValue,
onChange,
});
const color = useMemo(() => rgbaStringToHsva(currentValue), [currentValue]);
function onColorChange(color: ColorResult) {
const rgbaString = hsvaToRgbaString(color.hsva);
setCurrentValue(rgbaString);
}
return (
<Popover>
{trigger ?? (
<PopoverTrigger>
<div
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
style={{ backgroundColor: currentValue }}
/>
</PopoverTrigger>
)}
<PopoverContent align="start" className="min-w-xs">
{children && (
<>
{children}
<Separator />
</>
)}
<div className="flex flex-col gap-2">
<span className="font-medium text-muted-foreground text-xs">
<Trans>Presets</Trans>
</span>
<div className="grid grid-cols-8 gap-3 rounded bg-muted p-3">
{presetColors.map((color) => (
<button
key={color}
type="button"
title={color}
style={{ backgroundColor: color }}
aria-label={t`Use color ${color}`}
aria-pressed={currentValue === color}
onClick={() => setCurrentValue(color)}
className={cn(
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
currentValue === color && "border border-foreground/60",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="font-medium text-muted-foreground text-xs">
<Trans>Custom</Trans>
</span>
<div className="rounded bg-muted p-3 *:w-full! [&_.w-color-alpha>div]:rounded-full! [&_.w-color-alpha]:mt-4! [&_.w-color-alpha]:h-4! [&_.w-color-hue]:mt-4! [&_.w-color-hue]:h-4! [&_.w-color-hue]:rounded-full! [&_.w-color-saturation]:h-36! [&_.w-color-saturation]:rounded-[calc(var(--radius-lg)-0.25rem)]!">
<ReactColorColorful color={color} onChange={onColorChange} />
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,31 @@
import { t } from "@lingui/core/macro";
import { GithubLogoIcon, StarIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@reactive-resume/ui/components/button";
import { orpc } from "@/libs/orpc/client";
import { CountUp } from "../animation/count-up";
export function GithubStarsButton() {
const { data: starCount } = useQuery(orpc.statistics.github.getStarCount.queryOptions());
const ariaLabel =
starCount != null
? t`Star us on GitHub, currently ${starCount.toLocaleString()} stars (opens in new tab)`
: t`Star us on GitHub (opens in new tab)`;
return (
<Button
variant="outline"
nativeButton={false}
render={
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
<GithubLogoIcon aria-hidden="true" />
{starCount != null ? (
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
) : null}
<StarIcon aria-hidden="true" />
</a>
}
/>
);
}
@@ -0,0 +1,126 @@
import type { IconName } from "@reactive-resume/schema/icons";
import type { CellComponentProps } from "react-window";
import { t } from "@lingui/core/macro";
import { ProhibitIcon } from "@phosphor-icons/react";
import Fuse from "fuse.js";
import { memo, useCallback, useMemo, useState } from "react";
import { Grid } from "react-window";
import { icons } from "@reactive-resume/schema/icons";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { cn } from "@reactive-resume/utils/style";
const columnCount = 8;
const columnWidth = 36;
const rowHeight = 36;
type IconSearchInputProps = {
value: string;
onChange: (value: string) => void;
className?: string;
};
function _IconSearchInput(props: IconSearchInputProps) {
return (
<Input
spellCheck={false}
inputMode="search"
value={props.value}
aria-label={t({
comment: "Accessible label for icon picker search input",
message: "Search for an icon",
})}
placeholder={t({
comment: "Placeholder text in icon picker search input",
message: "Search for an icon",
})}
onChange={(e) => props.onChange(e.currentTarget.value)}
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
/>
);
}
const IconSearchInput = memo(_IconSearchInput);
IconSearchInput.displayName = "IconSearchInput";
type IconCellComponentProps = CellComponentProps & {
icons: IconName[];
onChange: (icon: IconName) => void;
};
function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: IconCellComponentProps) {
const index = rowIndex * columnCount + columnIndex;
const icon = icons[index];
return (
<button
type="button"
title={icon}
style={style}
tabIndex={-1}
onClick={() => onChange(icon)}
className="flex size-full items-center justify-center hover:bg-accent"
>
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
</button>
);
}
function useIconSearch() {
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
const search = useCallback(
(query: string): IconName[] => {
if (!query.trim()) return Array.from(icons);
return fuse.search(query).map((result) => result.item);
},
[fuse],
);
return search;
}
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
value: string;
onChange: (icon: string) => void;
popoverProps?: React.ComponentProps<typeof Popover>;
};
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
const searchIcons = useIconSearch();
const [search, setSearch] = useState("");
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
return (
<Popover {...popoverProps}>
<PopoverTrigger
render={
<Button size="icon" variant="outline" {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
}
/>
<PopoverContent align="start" className="h-[326px] w-[290px] gap-0 p-0">
<IconSearchInput value={search} onChange={setSearch} />
<div className="size-[290px]">
<Grid
key={search}
rowCount={rowCount}
rowHeight={rowHeight}
columnCount={columnCount}
columnWidth={columnWidth}
cellComponent={IconCellComponent}
cellProps={{ icons: searchedIcons, onChange }}
/>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,788 @@
import type { Editor, UseEditorOptions } from "@tiptap/react";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CodeBlockIcon,
CodeSimpleIcon,
ColumnsPlusLeftIcon,
ColumnsPlusRightIcon,
HighlighterCircleIcon,
KeyReturnIcon,
LinkBreakIcon,
LinkIcon,
ListBulletsIcon,
ListNumbersIcon,
MinusIcon,
ParagraphIcon,
PlusIcon,
RowsPlusBottomIcon,
RowsPlusTopIcon,
TableIcon,
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextBolderIcon,
TextHFiveIcon,
TextHFourIcon,
TextHOneIcon,
TextHSixIcon,
TextHThreeIcon,
TextHTwoIcon,
TextIndentIcon,
TextItalicIcon,
TextOutdentIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Toggle } from "@reactive-resume/ui/components/toggle";
import { cn } from "@reactive-resume/utils/style";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/libs/locale";
import { ColorPicker } from "./color-picker";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const extensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
codeBlock: {
enableTabIndentation: true,
},
link: {
openOnClick: false,
enableClickSelection: true,
defaultProtocol: "https",
protocols: ["http", "https"],
},
}),
TextStyle,
Color,
Highlight.configure({
HTMLAttributes: {
class: "rounded-md px-0.5 py-px",
},
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
type Props = UseEditorOptions & {
value: string;
onChange: (value: string) => void;
style?: React.CSSProperties;
className?: string;
editorClassName?: string;
};
export function RichInput({ value, onChange, style, className, editorClassName, ...options }: Props) {
const { i18n } = useLingui();
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
const [isFullscreen, setIsFullscreen] = useState(false);
const editor = useEditor({
...options,
extensions,
textDirection,
content: value,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
spellcheck: "false",
"data-editor": "true",
"data-fullscreen": isFullscreen ? "true" : "false",
class: cn(
"group/editor overflow-y-auto p-3 pb-4",
"rounded-md rounded-t-none border outline-none focus-visible:border-ring",
"[td:has(.selectedCell)]:bg-primary",
"data-[fullscreen=false]:max-h-[400px] data-[fullscreen=false]:min-h-[100px]",
"data-[fullscreen=true]:max-h-none data-[fullscreen=true]:min-h-full",
editorClassName,
),
},
},
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
const providerValue = useMemo(() => ({ editor }), [editor]);
if (!editor) return null;
const editorElement = (
<div className="relative">
<EditorToolbar editor={editor} isFullscreen={isFullscreen} />
<EditorContent editor={editor} />
<Button
size="icon"
variant="secondary"
className="absolute right-2 bottom-2 size-7"
title={isFullscreen ? t`Exit Fullscreen` : t`Fullscreen`}
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <ArrowsInSimpleIcon className="size-4" /> : <ArrowsOutSimpleIcon className="size-4" />}
</Button>
</div>
);
if (isFullscreen) {
return (
<EditorContext value={providerValue}>
<div className={cn("rounded-md", className)} style={style}>
{/* Placeholder to maintain layout */}
<div className="h-[200px] rounded-md border border-dashed" />
</div>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent className="flex h-[95svh] max-h-none! w-[95svw] max-w-none! flex-col p-4 sm:max-w-none! 2xl:max-w-none!">
<div className="sr-only">
<DialogTitle>
<Trans comment="Screen reader title for the fullscreen rich-text editor dialog">
Fullscreen Editor
</Trans>
</DialogTitle>
<DialogDescription>
<Trans comment="Screen reader description for the fullscreen rich-text editor dialog">
Edit content in fullscreen mode
</Trans>
</DialogDescription>
</div>
{editorElement}
</DialogContent>
</Dialog>
</EditorContext>
);
}
return (
<EditorContext value={providerValue}>
<div className={cn("rounded-md", className)} style={style}>
{editorElement}
</div>
</EditorContext>
);
}
function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen: boolean }) {
const prompt = usePrompt();
const state = useEditorState({
editor,
selector: (ctx) => {
return {
// Bold
isBold: ctx.editor.isActive("bold") ?? false,
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
toggleBold: () => ctx.editor.chain().focus().toggleBold().run(),
// Italic
isItalic: ctx.editor.isActive("italic") ?? false,
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
toggleItalic: () => ctx.editor.chain().focus().toggleItalic().run(),
// Underline
isUnderline: ctx.editor.isActive("underline") ?? false,
canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false,
toggleUnderline: () => ctx.editor.chain().focus().toggleUnderline().run(),
// Strike
isStrike: ctx.editor.isActive("strike") ?? false,
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
// Highlight
isHighlight: ctx.editor.isActive("highlight") ?? false,
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
// Text Color
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
canTextColor: ctx.editor.can().chain().setColor(defaultTextColor).run() ?? false,
setTextColor: (color: string) => ctx.editor.chain().focus().setColor(color).run(),
unsetTextColor: () => ctx.editor.chain().focus().unsetColor().run(),
// Heading 1
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false,
toggleHeading1: () => ctx.editor.chain().focus().toggleHeading({ level: 1 }).run(),
// Heading 2
isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false,
canHeading2: ctx.editor.can().chain().toggleHeading({ level: 2 }).run() ?? false,
toggleHeading2: () => ctx.editor.chain().focus().toggleHeading({ level: 2 }).run(),
// Heading 3
isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false,
canHeading3: ctx.editor.can().chain().toggleHeading({ level: 3 }).run() ?? false,
toggleHeading3: () => ctx.editor.chain().focus().toggleHeading({ level: 3 }).run(),
// Heading 4
isHeading4: ctx.editor.isActive("heading", { level: 4 }) ?? false,
canHeading4: ctx.editor.can().chain().toggleHeading({ level: 4 }).run() ?? false,
toggleHeading4: () => ctx.editor.chain().focus().toggleHeading({ level: 4 }).run(),
// Heading 5
isHeading5: ctx.editor.isActive("heading", { level: 5 }) ?? false,
canHeading5: ctx.editor.can().chain().toggleHeading({ level: 5 }).run() ?? false,
toggleHeading5: () => ctx.editor.chain().focus().toggleHeading({ level: 5 }).run(),
// Heading 6
isHeading6: ctx.editor.isActive("heading", { level: 6 }) ?? false,
canHeading6: ctx.editor.can().chain().toggleHeading({ level: 6 }).run() ?? false,
toggleHeading6: () => ctx.editor.chain().focus().toggleHeading({ level: 6 }).run(),
// Paragraph
isParagraph: ctx.editor.isActive("paragraph") ?? false,
canParagraph: ctx.editor.can().chain().setParagraph().run() ?? false,
setParagraph: () => ctx.editor.chain().focus().setParagraph().run(),
// Left Align
isLeftAlign: ctx.editor.isActive({ textAlign: "left" }) ?? false,
canLeftAlign: ctx.editor.can().chain().toggleTextAlign("left").run() ?? false,
toggleLeftAlign: () => ctx.editor.chain().focus().toggleTextAlign("left").run(),
// Center Align
isCenterAlign: ctx.editor.isActive({ textAlign: "center" }) ?? false,
canCenterAlign: ctx.editor.can().chain().toggleTextAlign("center").run() ?? false,
toggleCenterAlign: () => ctx.editor.chain().focus().toggleTextAlign("center").run(),
// Right Align
isRightAlign: ctx.editor.isActive({ textAlign: "right" }) ?? false,
canRightAlign: ctx.editor.can().chain().toggleTextAlign("right").run() ?? false,
toggleRightAlign: () => ctx.editor.chain().focus().toggleTextAlign("right").run(),
// Justify Align
isJustifyAlign: ctx.editor.isActive({ textAlign: "justify" }) ?? false,
canJustifyAlign: ctx.editor.can().chain().toggleTextAlign("justify").run() ?? false,
toggleJustifyAlign: () => ctx.editor.chain().focus().toggleTextAlign("justify").run(),
// Bullet List
isBulletList: ctx.editor.isActive("bulletList") ?? false,
canBulletList: ctx.editor.can().chain().toggleBulletList().run() ?? false,
toggleBulletList: () => ctx.editor.chain().focus().toggleBulletList().run(),
// Ordered List
isOrderedList: ctx.editor.isActive("orderedList") ?? false,
canOrderedList: ctx.editor.can().chain().toggleOrderedList().run() ?? false,
toggleOrderedList: () => ctx.editor.chain().focus().toggleOrderedList().run(),
// Outdent List Item
canLiftListItem: ctx.editor.can().chain().liftListItem("listItem").run() ?? false,
liftListItem: () => ctx.editor.chain().focus().liftListItem("listItem").run(),
// Indent List Item
canSinkListItem: ctx.editor.can().chain().sinkListItem("listItem").run() ?? false,
sinkListItem: () => ctx.editor.chain().focus().sinkListItem("listItem").run(),
// Link
isLink: ctx.editor.isActive("link") ?? false,
setLink: async () => {
const url = await prompt(t`Please enter the URL you want to link to:`, {
defaultValue: "https://",
});
if (!url || url.trim() === "") {
ctx.editor.chain().focus().unsetLink().run();
return;
}
if (!z.url({ protocol: /^https?$/ }).safeParse(url).success) {
toast.error(t`The URL you entered is not valid.`, {
description: t`Valid URLs must start with http:// or https://.`,
});
return;
}
ctx.editor.chain().focus().setLink({ href: url, target: "_blank", rel: "noopener nofollow" }).run();
},
unsetLink: () => ctx.editor.chain().focus().unsetLink().run(),
// Inline Code
isInlineCode: ctx.editor.isActive("code") ?? false,
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
// Code Block
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
// Table
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
// Hard Break
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
// Horizontal Rule
setHorizontalRule: () => ctx.editor.chain().focus().setHorizontalRule().run(),
};
},
});
return (
<div className="flex flex-wrap items-center gap-y-0.5 rounded-md rounded-b-none border border-b-0">
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Bold`}
pressed={state.isBold}
disabled={!state.canBold}
onPressedChange={state.toggleBold}
>
<TextBolderIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Italic`}
pressed={state.isItalic}
disabled={!state.canItalic}
onPressedChange={state.toggleItalic}
>
<TextItalicIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Underline`}
pressed={state.isUnderline}
disabled={!state.canUnderline}
onPressedChange={state.toggleUnderline}
>
<TextUnderlineIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Strike`}
pressed={state.isStrike}
disabled={!state.canStrike}
onPressedChange={state.toggleStrike}
>
<TextStrikethroughIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Highlight`}
pressed={state.isHighlight}
disabled={!state.canHighlight}
onPressedChange={state.toggleHighlight}
>
<HighlighterCircleIcon className="size-3.5" />
</Toggle>
<ColorPicker
defaultValue={defaultTextColor}
value={state.textColor ?? undefined}
onChange={state.setTextColor}
trigger={
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
title={t`Text Color`}
disabled={!state.canTextColor}
>
<span className="flex flex-col items-center leading-none">
<span className="font-semibold text-xs">A</span>
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: state.textColor ?? "currentColor" }}
/>
</span>
</Button>
}
/>
}
>
<PopoverHeader className="flex-row items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 font-semibold text-sm shadow-xs"
style={{ color: state.textColor ?? "currentColor" }}
>
A
</span>
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Text Color</Trans>
</PopoverTitle>
<span className="text-muted-foreground text-xs">
<Trans comment="Preset or custom shade refer to the color picker">
Choose a preset or custom shade.
</Trans>
</span>
</div>
</div>
<Button
size="xs"
variant="ghost"
className="shrink-0"
onClick={state.unsetTextColor}
disabled={!state.textColor}
>
<Trans comment="Clear the text color">Clear</Trans>
</Button>
</PopoverHeader>
</ColorPicker>
<div className="mx-1 h-5 w-px bg-border" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
.with({ isHeading2: true }, () => <TextHTwoIcon className="size-3.5" />)
.with({ isHeading3: true }, () => <TextHThreeIcon className="size-3.5" />)
.with({ isHeading4: true }, () => <TextHFourIcon className="size-3.5" />)
.with({ isHeading5: true }, () => <TextHFiveIcon className="size-3.5" />)
.with({ isHeading6: true }, () => <TextHSixIcon className="size-3.5" />)
.otherwise(() => (
<ParagraphIcon className="size-3.5" />
))}
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
disabled={!state.canParagraph}
checked={state.isParagraph}
onCheckedChange={state.setParagraph}
>
<Trans>Paragraph</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
disabled={!state.canHeading1}
checked={state.isHeading1}
onCheckedChange={state.toggleHeading1}
>
<Trans>Heading 1</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canHeading2}
checked={state.isHeading2}
onCheckedChange={state.toggleHeading2}
>
<Trans>Heading 2</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canHeading3}
checked={state.isHeading3}
onCheckedChange={state.toggleHeading3}
>
<Trans>Heading 3</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canHeading4}
checked={state.isHeading4}
onCheckedChange={state.toggleHeading4}
>
<Trans>Heading 4</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canHeading5}
checked={state.isHeading5}
onCheckedChange={state.toggleHeading5}
>
<Trans>Heading 5</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canHeading6}
checked={state.isHeading6}
onCheckedChange={state.toggleHeading6}
>
<Trans>Heading 6</Trans>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
.with({ isRightAlign: true }, () => <TextAlignRightIcon className="size-3.5" />)
.with({ isJustifyAlign: true }, () => <TextAlignJustifyIcon className="size-3.5" />)
.otherwise(() => (
<TextAlignLeftIcon className="size-3.5" />
))}
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
disabled={!state.canLeftAlign}
checked={state.isLeftAlign}
onCheckedChange={state.toggleLeftAlign}
>
<Trans>Left Align</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canCenterAlign}
checked={state.isCenterAlign}
onCheckedChange={state.toggleCenterAlign}
>
<Trans>Center Align</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canRightAlign}
checked={state.isRightAlign}
onCheckedChange={state.toggleRightAlign}
>
<Trans>Right Align</Trans>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
disabled={!state.canJustifyAlign}
checked={state.isJustifyAlign}
onCheckedChange={state.toggleJustifyAlign}
>
<Trans>Justify Align</Trans>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="mx-1 h-5 w-px bg-border" />
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Bullet List`}
pressed={state.isBulletList}
disabled={!state.canBulletList}
onPressedChange={state.toggleBulletList}
>
<ListBulletsIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Ordered List`}
pressed={state.isOrderedList}
disabled={!state.canOrderedList}
onPressedChange={state.toggleOrderedList}
>
<ListNumbersIcon className="size-3.5" />
</Toggle>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
disabled={!state.canLiftListItem}
onClick={state.liftListItem}
>
<TextOutdentIcon className="size-3.5" />
</Button>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
disabled={!state.canSinkListItem}
onClick={state.sinkListItem}
>
<TextIndentIcon className="size-3.5" />
</Button>
<div className="mx-1 h-5 w-px bg-border" />
{state.isLink ? (
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
onClick={state.unsetLink}
>
<LinkBreakIcon className="size-3.5" />
</Button>
) : (
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
onClick={state.setLink}
>
<LinkIcon className="size-3.5" />
</Button>
)}
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Inline Code`}
pressed={state.isInlineCode}
disabled={!state.canInlineCode}
onPressedChange={state.toggleInlineCode}
>
<CodeSimpleIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Code Block`}
pressed={state.isCodeBlock}
disabled={!state.canCodeBlock}
onPressedChange={state.toggleCodeBlock}
>
<CodeBlockIcon className="size-3.5" />
</Toggle>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
<PlusIcon />
<Trans>Insert Table</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
<ColumnsPlusLeftIcon />
<Trans>Add Column Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
<ColumnsPlusRightIcon />
<Trans>Add Column After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
<RowsPlusTopIcon />
<Trans>Add Row Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
<RowsPlusBottomIcon />
<Trans>Add Row After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
<TrashSimpleIcon />
<Trans>Delete Column</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
<TrashSimpleIcon />
<Trans>Delete Row</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
<TrashSimpleIcon />
<Trans>Delete Table</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`New Line`}
onClick={state.setHardBreak}
>
<KeyReturnIcon className="size-3.5" />
</Button>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Separator`}
onClick={state.setHorizontalRule}
>
<MinusIcon className="size-3.5" />
</Button>
</div>
);
}
@@ -0,0 +1,99 @@
import type { Website } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TagIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Label } from "@reactive-resume/ui/components/label";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { cn } from "@reactive-resume/utils/style";
const PREFIX = "https://";
function stripPrefix(url: string) {
return url.startsWith(PREFIX) ? url.slice(PREFIX.length) : url;
}
function ensurePrefix(url: string) {
if (url === "") return "";
return url.startsWith(PREFIX) ? url : PREFIX + url;
}
type Props<TValue extends Website = Website> = Omit<React.ComponentProps<"input">, "value" | "onChange"> & {
value: TValue;
onChange: (value: TValue) => void;
hideLabelButton?: boolean;
};
export function URLInput<TValue extends Website>({ value, onChange, hideLabelButton, ...props }: Props<TValue>) {
const handleUrlChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...value,
url: ensurePrefix(e.target.value),
});
},
[onChange, value],
);
const handleLabelChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange({ ...value, label: e.target.value });
},
[onChange, value],
);
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
return (
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>{PREFIX}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
value={urlValue}
className={cn(props.className, "ps-0!")}
onChange={handleUrlChange}
{...props}
/>
{!hideLabelButton && (
<InputGroupAddon align="inline-end">
<Popover>
<PopoverTrigger
render={
<InputGroupButton
size="icon-sm"
title={t({
comment: "Tooltip for action button that opens URL label editor",
message: "Add a label to the URL",
})}
>
<TagIcon />
</InputGroupButton>
}
/>
<PopoverContent className="pt-3">
{/** biome-ignore lint/a11y/noStaticElementInteractions: for stopPropagation */}
<div role="presentation" className="grid gap-2" onMouseDown={(e) => e.stopPropagation()}>
<Label htmlFor="url-label">
<Trans comment="Short field label for custom display text associated with a URL">Label</Trans>
</Label>
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
</div>
</PopoverContent>
</Popover>
</InputGroupAddon>
)}
</InputGroup>
);
}