From d9498b441ec0b926fa81cf7d9c67009f714be39f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 30 Apr 2025 18:22:02 +0100 Subject: [PATCH 1/9] Adds a tooltip to display the UTC, timezone and local time for DateTime --- .../app/components/primitives/DateTime.tsx | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index bef6281cc7..fddb3bf582 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -1,5 +1,9 @@ -import { Fragment, useEffect, useState } from "react"; +import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid"; +import { Laptop } from "lucide-react"; +import { Fragment, type ReactNode, useEffect, useState } from "react"; import { useLocales } from "./LocaleProvider"; +import { Paragraph } from "./Paragraph"; +import { SimpleTooltip } from "./Tooltip"; type DateTimeProps = { date: Date | string; @@ -18,8 +22,9 @@ export const DateTime = ({ showTimezone = false, }: DateTimeProps) => { const locales = useLocales(); - const realDate = typeof date === "string" ? new Date(date) : date; + const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + const localTimeZone = resolvedOptions.timeZone; const initialFormattedDateTime = formatDateTime( realDate, @@ -32,8 +37,6 @@ export const DateTime = ({ const [formattedDateTime, setFormattedDateTime] = useState(initialFormattedDateTime); useEffect(() => { - const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); - setFormattedDateTime( formatDateTime( realDate, @@ -45,11 +48,55 @@ export const DateTime = ({ ); }, [locales, includeSeconds, realDate]); + const tooltipContent = ( +
+ {!timeZone || timeZone === "UTC" ? ( +
+ } + /> + } + /> +
+ ) : ( +
+ } + /> + } + /> + } + /> +
+ )} +
+ ); + return ( - - {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} - {showTimezone ? ` (${timeZone ?? "UTC"})` : null} - + + {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} + {showTimezone ? ` (${timeZone ?? "UTC"})` : null} + + } + content={tooltipContent} + side="right" + // disableHoverableContent + /> ); }; @@ -226,3 +273,23 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s return formattedDateTime; } + +type DateTimeTooltipContentProps = { + title: string; + dateTime: string; + icon: ReactNode; +}; + +function DateTimeTooltipContent({ title, dateTime, icon }: DateTimeTooltipContentProps) { + return ( +
+
+ {icon} + {title} +
+ + {dateTime} + +
+ ); +} From f1638ab1bd9da2f44b39c463de9048445e4dbb31 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 30 Apr 2025 18:35:21 +0100 Subject: [PATCH 2/9] Made it safer for SSR --- .../app/components/primitives/DateTime.tsx | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index fddb3bf582..be4a78975a 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -22,31 +22,14 @@ export const DateTime = ({ showTimezone = false, }: DateTimeProps) => { const locales = useLocales(); - const realDate = typeof date === "string" ? new Date(date) : date; - const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); - const localTimeZone = resolvedOptions.timeZone; - - const initialFormattedDateTime = formatDateTime( - realDate, - timeZone ?? "UTC", - locales, - includeSeconds, - includeTime - ); + const [localTimeZone, setLocalTimeZone] = useState("UTC"); - const [formattedDateTime, setFormattedDateTime] = useState(initialFormattedDateTime); + const realDate = typeof date === "string" ? new Date(date) : date; useEffect(() => { - setFormattedDateTime( - formatDateTime( - realDate, - timeZone ?? resolvedOptions.timeZone, - locales, - includeSeconds, - includeTime - ) - ); - }, [locales, includeSeconds, realDate]); + const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + setLocalTimeZone(resolvedOptions.timeZone); + }, []); const tooltipContent = (
@@ -89,7 +72,13 @@ export const DateTime = ({ - {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} + {formatDateTime( + realDate, + timeZone ?? localTimeZone, + locales, + includeSeconds, + includeTime + ).replace(/\s/g, String.fromCharCode(32))} {showTimezone ? ` (${timeZone ?? "UTC"})` : null} } From cb5bbaf983df0dc6aa02546f12a1a6014f493884 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 30 Apr 2025 19:09:58 +0100 Subject: [PATCH 3/9] Makes the copy function into a hook and separate button + adds copy dateTime button to the date tooltip --- .../components/primitives/ClipboardField.tsx | 104 +++++------------- .../app/components/primitives/CopyButton.tsx | 86 +++++++++++++++ .../components/primitives/CopyableText.tsx | 18 +-- .../app/components/primitives/DateTime.tsx | 28 ++++- apps/webapp/app/hooks/useCopy.ts | 22 ++++ 5 files changed, 161 insertions(+), 97 deletions(-) create mode 100644 apps/webapp/app/components/primitives/CopyButton.tsx create mode 100644 apps/webapp/app/hooks/useCopy.ts 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..d0614a73c4 --- /dev/null +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -0,0 +1,86 @@ +import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; +import { cn } from "~/utils/cn"; +import { useCopy } from "~/hooks/useCopy"; +import { Button } from "./Buttons"; +import { SimpleTooltip } from "./Tooltip"; + +type CopyButtonProps = { + value: string; + variant?: "icon" | "button"; + size?: "small" | "medium"; + 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 iconSize = size === "small" ? "size-3.5" : "size-4"; + const buttonSize = size === "small" ? "h-6" : "h-8"; + + 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..b7eab90023 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 { useState } from "react"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; import { cn } from "~/utils/cn"; +import { useCopy } from "~/hooks/useCopy"; 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 ( } /> } />
@@ -51,16 +54,19 @@ export const DateTime = ({ } /> } /> } /> @@ -84,7 +90,6 @@ export const DateTime = ({ } content={tooltipContent} side="right" - // disableHoverableContent /> ); }; @@ -107,6 +112,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(); @@ -266,19 +275,28 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s type DateTimeTooltipContentProps = { title: string; dateTime: string; + isoDateTime: string; icon: ReactNode; }; -function DateTimeTooltipContent({ title, dateTime, icon }: DateTimeTooltipContentProps) { +function DateTimeTooltipContent({ + title, + dateTime, + isoDateTime, + icon, +}: DateTimeTooltipContentProps) { return (
{icon} {title}
- - {dateTime} - +
+ + {dateTime} + + +
); } 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 }; +} From 3ebe0e17bec135aa95888501008c0a4bc849cf77 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 1 May 2025 07:36:50 +0100 Subject: [PATCH 4/9] Adds an extra-small size copy button --- .../app/components/primitives/CopyButton.tsx | 24 +++++++++++++++---- .../app/components/primitives/DateTime.tsx | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index d0614a73c4..330db5b0d2 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -4,10 +4,25 @@ import { useCopy } from "~/hooks/useCopy"; 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?: "small" | "medium"; + size?: keyof typeof sizes; className?: string; buttonClassName?: string; showTooltip?: boolean; @@ -25,8 +40,7 @@ export function CopyButton({ }: CopyButtonProps) { const { copy, copied } = useCopy(value); - const iconSize = size === "small" ? "size-3.5" : "size-4"; - const buttonSize = size === "small" ? "h-6" : "h-8"; + const { icon: iconSize, button: buttonSize } = sizes[size]; const button = variant === "icon" ? ( @@ -34,7 +48,7 @@ export function CopyButton({ onClick={copy} className={cn( buttonSize, - "flex items-center justify-center rounded border border-charcoal-650 bg-charcoal-750 px-1.5", + "flex items-center justify-center rounded border border-charcoal-650 bg-charcoal-750", copied ? "text-green-500" : "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright", @@ -49,7 +63,7 @@ export function CopyButton({ ) : (