diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index dcb6a87879..3d4028d926 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -1,8 +1,6 @@ -import { CheckIcon } from "@heroicons/react/20/solid"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; -import { Button } from "./Buttons"; -import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { CopyButton } from "./CopyButton"; const variants = { "primary/small": { @@ -10,61 +8,55 @@ const variants = { "flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-xs transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", - buttonVariant: "primary/small" as const, + buttonVariant: "primary" as const, + size: "small" as const, button: "rounded-l-none", - iconSize: "h-3 w-3", - iconPadding: "pl-1", }, "secondary/small": { container: "flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-xs transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", - buttonVariant: "tertiary/small" as const, + buttonVariant: "tertiary" as const, + size: "small" as const, button: "rounded-l-none border-l border-charcoal-750", - iconSize: "h-3 w-3", - iconPadding: "pl-1", }, "tertiary/small": { container: "group/clipboard flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-xs transition duration-150 hover:border-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-xs px-2 w-auto rounded-l h-6 leading-6 focus:ring-transparent", - buttonVariant: "minimal/small" as const, + buttonVariant: "minimal" as const, + size: "small" as const, button: "rounded-l-none border-l border-transparent transition group-hover/clipboard:border-charcoal-700", - iconSize: "h-3 w-3", - iconPadding: "pl-1", }, "primary/medium": { container: "flex items-center text-text-dimmed font-mono rounded border bg-charcoal-750 text-sm transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", - buttonVariant: "primary/medium" as const, + buttonVariant: "primary" as const, + size: "medium" as const, button: "rounded-l-none", - iconSize: "h-4 w-4", - iconPadding: "pl-2", }, "secondary/medium": { container: "flex items-center text-text-dimmed font-mono rounded bg-charcoal-750 text-sm transition hover:bg-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", - buttonVariant: "tertiary/medium" as const, + buttonVariant: "tertiary" as const, + size: "medium" as const, button: "rounded-l-none border-l border-charcoal-750", - iconSize: "h-4 w-4", - iconPadding: "pl-2", }, "tertiary/medium": { container: "group flex items-center text-text-dimmed font-mono rounded bg-transparent border border-transparent text-sm transition hover:border-charcoal-700 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-transparent focus:outline-none focus:ring-0 focus:ring-transparent", input: "bg-transparent border-0 text-sm px-3 w-auto rounded-l h-8 leading-6 focus:ring-transparent", - buttonVariant: "minimal/medium" as const, + buttonVariant: "minimal" as const, + size: "medium" as const, button: "rounded-l-none border-l border-transparent transition group-hover:border-charcoal-700", - iconSize: "h-4 w-4", - iconPadding: "pl-2", }, }; @@ -88,36 +80,19 @@ export function ClipboardField({ fullWidth = true, }: ClipboardFieldProps) { const [isSecure, setIsSecure] = useState(secure !== undefined && secure); - const [copied, setCopied] = useState(false); - - const copy = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1500); - }, - [value] - ); + const inputIcon = useRef(null); + const { container, input, buttonVariant, button, size } = variants[variant]; useEffect(() => { setIsSecure(secure !== undefined && secure); }, [secure]); - const { container, input, buttonVariant, button } = variants[variant]; - const iconClassName = variants[variant].iconSize; - const iconPosition = variants[variant].iconPadding; - const inputIcon = useRef(null); - return ( {icon && ( inputIcon.current && inputIcon.current.focus()} - className={cn(iconPosition, "flex items-center")} + className="flex items-center pl-1" > {icon} @@ -132,51 +107,26 @@ export function ClipboardField({ fullWidth ? "w-full" : "max-w-fit", input )} - // size={value.length} - // maxLength={3} onFocus={(e) => { if (secure) { - setIsSecure((i) => false); + setIsSecure(false); } e.currentTarget.select(); }} onBlur={() => { if (secure) { - setIsSecure((i) => true); + setIsSecure(true); } }} /> - {iconButton ? ( - - ) : ( - - )} + ); } diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx new file mode 100644 index 0000000000..1c1d611987 --- /dev/null +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -0,0 +1,100 @@ +import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { useCopy } from "~/hooks/useCopy"; +import { cn } from "~/utils/cn"; +import { Button } from "./Buttons"; +import { SimpleTooltip } from "./Tooltip"; + +const sizes = { + "extra-small": { + icon: "size-3", + button: "h-5 px-1", + }, + small: { + icon: "size-3.5", + button: "h-6 px-1", + }, + medium: { + icon: "size-4", + button: "h-8 px-1.5", + }, +}; + +type CopyButtonProps = { + value: string; + variant?: "icon" | "button"; + size?: keyof typeof sizes; + className?: string; + buttonClassName?: string; + showTooltip?: boolean; + buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal"; +}; + +export function CopyButton({ + value, + variant = "button", + size = "medium", + className, + buttonClassName, + showTooltip = true, + buttonVariant = "tertiary", +}: CopyButtonProps) { + const { copy, copied } = useCopy(value); + + const { icon: iconSize, button: buttonSize } = sizes[size]; + + const button = + variant === "icon" ? ( + + {copied ? ( + + ) : ( + + )} + + ) : ( + + ); + + if (!showTooltip) return {button}; + + return ( + + + + ); +} diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index 4417db8710..6a37c6f2bf 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -1,24 +1,12 @@ -import { useCallback, useState } from "react"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { useState } from "react"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useCopy } from "~/hooks/useCopy"; import { cn } from "~/utils/cn"; export function CopyableText({ value, className }: { value: string; className?: string }) { const [isHovered, setIsHovered] = useState(false); - const [copied, setCopied] = useState(false); - - const copy = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1500); - }, - [value] - ); + const { copy, copied } = useCopy(value); return ( { const locales = useLocales(); + const [localTimeZone, setLocalTimeZone] = useState("UTC"); const realDate = typeof date === "string" ? new Date(date) : date; - const initialFormattedDateTime = formatDateTime( - realDate, - timeZone ?? "UTC", - locales, - includeSeconds, - includeTime - ); - - const [formattedDateTime, setFormattedDateTime] = useState(initialFormattedDateTime); - useEffect(() => { const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + setLocalTimeZone(resolvedOptions.timeZone); + }, []); + + const tooltipContent = ( +
+
+ {timeZone && timeZone !== "UTC" && ( + } + /> + )} + } + /> + } + /> +
+
+ ); - setFormattedDateTime( - formatDateTime( + const formattedDateTime = ( + + {formatDateTime( realDate, - timeZone ?? resolvedOptions.timeZone, + timeZone ?? localTimeZone, locales, includeSeconds, includeTime - ) - ); - }, [locales, includeSeconds, realDate]); - - return ( - - {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} + ).replace(/\s/g, String.fromCharCode(32))} {showTimezone ? ` (${timeZone ?? "UTC"})` : null} ); + + if (!showTooltip) return formattedDateTime; + + return ; }; export function formatDateTime( @@ -71,6 +97,10 @@ export function formatDateTime( }).format(date); } +export function formatDateTimeISO(date: Date, timeZone: string): string { + return new Date(date.toLocaleString("en-US", { timeZone })).toISOString(); +} + // New component that only shows date when it changes export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => { const locales = useLocales(); @@ -226,3 +256,42 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s return formattedDateTime; } + +type DateTimeTooltipContentProps = { + title: string; + dateTime: string; + isoDateTime: string; + icon: ReactNode; +}; + +function DateTimeTooltipContent({ + title, + dateTime, + isoDateTime, + icon, +}: DateTimeTooltipContentProps) { + const getUtcOffset = () => { + if (title !== "Local") return ""; + const offset = -new Date().getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + const hours = Math.abs(Math.floor(offset / 60)); + const minutes = Math.abs(offset % 60); + return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`; + }; + + return ( +
+
+ {icon} + {title} + {getUtcOffset()} +
+
+ + {dateTime} + + +
+
+ ); +} diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 53a5f4959c..354c6573e8 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -1,7 +1,7 @@ -import * as React from "react"; +import { InformationCircleIcon } from "@heroicons/react/20/solid"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import * as React from "react"; import { cn } from "~/utils/cn"; -import { InformationCircleIcon } from "@heroicons/react/20/solid"; const variantClasses = { basic: @@ -115,4 +115,4 @@ export function InfoIconTooltip({ ); } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip }; +export { SimpleTooltip, Tooltip, TooltipArrow, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/apps/webapp/app/hooks/useCopy.ts b/apps/webapp/app/hooks/useCopy.ts new file mode 100644 index 0000000000..00d63c51bc --- /dev/null +++ b/apps/webapp/app/hooks/useCopy.ts @@ -0,0 +1,22 @@ +import { useCallback, useState } from "react"; + +export function useCopy(value: string, duration = 1500) { + const [copied, setCopied] = useState(false); + + const copy = useCallback( + (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, duration); + }, + [value, duration] + ); + + return { copy, copied }; +}