feat: migrate to Radix UI and implement dark mode with next-themes

Replace @headlessui/react and @heroicons/react with @radix-ui/themes and
@radix-ui/react-icons for a more comprehensive component library. Add
next-themes for dark mode support with a new ThemeToggle component. Update
all components to use Radix UI primitives and theming system, including
AbstractCard, DynamicDate, ErrorCard, Property, PropertyList, LookupInput,
and all card components (AutnumCard, DomainCard, EntityCard, IPCard,
NameserverCard). Update global styles and app configuration to support theme
switching.
This commit is contained in:
2025-10-22 11:59:52 -05:00
parent 611074b546
commit 7073936e6c
19 changed files with 2161 additions and 550 deletions

View File

@@ -22,12 +22,13 @@
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7",
"@headlessui/react": "^2.2.9", "@radix-ui/react-icons": "^1.3.2",
"@heroicons/react": "^2.0.16", "@radix-ui/themes": "^3.2.1",
"@swc/helpers": "^0.5.11", "@swc/helpers": "^0.5.11",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"next": "^15.5.6", "next": "^15.5.6",
"next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.42.1",

1788
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,8 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import React from "react"; import React from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { import { Link2Icon, CodeIcon, DownloadIcon, ClipboardCopyIcon } from "@radix-ui/react-icons";
LinkIcon, import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes";
CodeBracketIcon,
DocumentArrowDownIcon,
ClipboardDocumentIcon,
} from "@heroicons/react/24/outline";
type AbstractCardProps = { type AbstractCardProps = {
children?: ReactNode; children?: ReactNode;
@@ -26,82 +22,125 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
const { value: showRaw, toggle: toggleRaw } = useBoolean(false); const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
return ( return (
<div className="mb-4 overflow-clip rounded bg-zinc-800 shadow"> <Box mb="4">
{header != undefined || data != undefined ? ( <Card size="2">
<div className="flex bg-zinc-700 p-2 pl-3 md:pl-5"> {(header != undefined || data != undefined) && (
<div className="flex grow gap-2">{header}</div> <Flex
{url != undefined ? ( justify="between"
<div className="pr-2"> align="center"
<a href={url} target="_blank" rel="noreferrer"> p="3"
<LinkIcon className="mt-1 h-5 w-5 cursor-pointer" /> style={{
</a> borderBottom: "1px solid var(--gray-a5)",
</div> }}
) : null} >
{data != undefined ? ( <Flex gap="2" style={{ flex: 1 }}>
<> {header}
<div className="pr-2"> </Flex>
<ClipboardDocumentIcon <Flex gap="2" align="center">
onClick={() => { {url != undefined && (
// stringify the JSON object, then begin the async clipboard write <IconButton variant="ghost" size="2" asChild>
navigator.clipboard <a
.writeText(JSON.stringify(data, null, 4)) href={url}
.then( target="_blank"
() => { rel="noreferrer"
// Successfully copied to clipboard aria-label="Open RDAP URL"
}, >
(err) => { <Link2Icon width="18" height="18" />
if (err instanceof Error) </a>
console.error( </IconButton>
`Failed to copy to clipboard (${err.toString()}).` )}
); {data != undefined && (
else <>
console.error( <IconButton
"Failed to copy to clipboard." variant="ghost"
); size="2"
} onClick={() => {
); navigator.clipboard
}} .writeText(JSON.stringify(data, null, 4))
className="h-6 w-6 cursor-pointer" .then(
/> () => {
</div> // Successfully copied to clipboard
<div className="pr-2"> },
<DocumentArrowDownIcon (err) => {
onClick={() => { if (err instanceof Error)
const file = new Blob([JSON.stringify(data, null, 4)], { console.error(
type: "application/json", `Failed to copy to clipboard (${err.toString()}).`
}); );
else
console.error(
"Failed to copy to clipboard."
);
}
);
}}
aria-label="Copy JSON to clipboard"
>
<ClipboardCopyIcon width="18" height="18" />
</IconButton>
<IconButton
variant="ghost"
size="2"
onClick={() => {
const file = new Blob([JSON.stringify(data, null, 4)], {
type: "application/json",
});
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = URL.createObjectURL(file); anchor.href = URL.createObjectURL(file);
anchor.download = "response.json"; anchor.download = "response.json";
anchor.click(); anchor.click();
}} }}
className="h-6 w-6 cursor-pointer" aria-label="Download JSON"
/> >
</div> <DownloadIcon width="18" height="18" />
<div className="pr-1"> </IconButton>
<CodeBracketIcon <IconButton
onClick={toggleRaw} variant="ghost"
className="h-6 w-6 cursor-pointer" size="2"
/> onClick={toggleRaw}
</div> aria-label={
</> showRaw ? "Show formatted view" : "Show raw JSON"
) : null} }
</div> >
) : null} <CodeIcon width="18" height="18" />
<div className="max-w-full overflow-x-auto p-2 px-4"> </IconButton>
{showRaw ? ( </>
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto rounded whitespace-pre-wrap"> )}
{JSON.stringify(data, null, 4)} </Flex>
</pre> </Flex>
) : (
children
)} )}
</div> <Box p="4">
{footer != null ? ( {showRaw ? (
<div className="flex gap-2 bg-zinc-700 p-2 pl-5">{footer}</div> <ScrollArea type="auto" scrollbars="both" style={{ maxHeight: "40rem" }}>
) : null} <Code
</div> variant="ghost"
size="2"
style={{
display: "block",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono)",
}}
>
{JSON.stringify(data, null, 4)}
</Code>
</ScrollArea>
) : (
children
)}
</Box>
{footer != null && (
<Flex
gap="2"
p="3"
style={{
borderTop: "1px solid var(--gray-a5)",
}}
>
{footer}
</Flex>
)}
</Card>
</Box>
); );
}; };

View File

@@ -2,6 +2,7 @@ import type { FunctionComponent } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { format } from "date-fns"; import { format } from "date-fns";
import TimeAgo from "react-timeago"; import TimeAgo from "react-timeago";
import { Button, Text } from "@radix-ui/themes";
type DynamicDateProps = { type DynamicDateProps = {
value: Date | number; value: Date | number;
@@ -18,15 +19,20 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({ value, absoluteForma
const date = new Date(value); const date = new Date(value);
return ( return (
<button onClick={toggleFormat}> <Button
<span className="dashed" title={date.toISOString()}> variant="ghost"
size="1"
onClick={toggleFormat}
style={{ padding: 0, height: "auto" }}
>
<Text className="dashed" title={date.toISOString()} size="2">
{showAbsolute ? ( {showAbsolute ? (
format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx") format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
) : ( ) : (
<TimeAgo date={date} /> <TimeAgo date={date} />
)} )}
</span> </Text>
</button> </Button>
); );
}; };

View File

@@ -1,6 +1,6 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import { XCircleIcon } from "@heroicons/react/20/solid"; import { CrossCircledIcon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils"; import { Callout, Box, Flex } from "@radix-ui/themes";
export type ErrorCardProps = { export type ErrorCardProps = {
title: ReactNode; title: ReactNode;
@@ -16,35 +16,46 @@ const ErrorCard: FunctionComponent<ErrorCardProps> = ({
className, className,
}) => { }) => {
return ( return (
<div <Box className={className}>
className={cn( <Callout.Root color="red" role="alert">
className, <Callout.Icon>
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pt-3 pb-1" <CrossCircledIcon />
)} </Callout.Icon>
> <Flex direction="column" gap="2">
<div className="flex"> <Callout.Text weight="medium" size="3">
<div className="shrink-0"> {title}
<XCircleIcon className="h-5 w-5 text-red-300" aria-hidden="true" /> </Callout.Text>
</div>
<div className="ml-3 w-full text-sm text-red-300">
<h3 className="font-medium text-red-200">{title}</h3>
{description != undefined ? ( {description != undefined ? (
<div className="mt-2 max-h-24 w-full overflow-y-auto whitespace-pre-wrap"> <Box
{description} style={{
</div> maxHeight: "6rem",
overflowY: "auto",
whiteSpace: "pre-wrap",
}}
>
<Callout.Text size="2">{description}</Callout.Text>
</Box>
) : null} ) : null}
<div className="mt-2"> {issues != undefined && issues.length > 0 ? (
{issues != undefined ? ( <Box asChild>
<ul role="list" className="flex list-disc flex-col gap-1 pl-5"> <ul
role="list"
style={{
listStyleType: "disc",
paddingLeft: "1.25rem",
}}
>
{issues.map((issueText, index) => ( {issues.map((issueText, index) => (
<li key={index}>{issueText}</li> <li key={index}>
<Callout.Text size="2">{issueText}</Callout.Text>
</li>
))} ))}
</ul> </ul>
) : null} </Box>
</div> ) : null}
</div> </Flex>
</div> </Callout.Root>
</div> </Box>
); );
}; };

View File

@@ -1,6 +1,6 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import React from "react"; import React from "react";
import { cn } from "@/lib/utils"; import { DataList } from "@radix-ui/themes";
type PropertyProps = { type PropertyProps = {
title: string | ReactNode; title: string | ReactNode;
@@ -9,6 +9,11 @@ type PropertyProps = {
valueClass?: string; valueClass?: string;
}; };
/**
* A simple wrapper around Radix DataList for displaying key-value pairs.
* This component uses DataList.Item, DataList.Label, and DataList.Value
* to provide semantic HTML and consistent styling.
*/
const Property: FunctionComponent<PropertyProps> = ({ const Property: FunctionComponent<PropertyProps> = ({
title, title,
children, children,
@@ -16,10 +21,10 @@ const Property: FunctionComponent<PropertyProps> = ({
valueClass, valueClass,
}) => { }) => {
return ( return (
<> <DataList.Item>
<dt className={cn("font-medium", titleClass)}>{title}:</dt> <DataList.Label className={titleClass}>{title}</DataList.Label>
<dd className={cn("mt-2 mb-2 ml-6", valueClass)}>{children}</dd> <DataList.Value className={valueClass}>{children}</DataList.Value>
</> </DataList.Item>
); );
}; };

View File

@@ -1,17 +1,19 @@
import type { FunctionComponent, ReactNode } from "react"; import type { FunctionComponent, ReactNode } from "react";
import React from "react"; import React from "react";
import Property from "@/components/common/Property"; import { Text, Box, DataList } from "@radix-ui/themes";
const PropertyListItem: FunctionComponent<{ const PropertyListItem: FunctionComponent<{
title: string; title: string;
children: string; children: string;
}> = ({ title, children }) => { }> = ({ title, children }) => {
return ( return (
<li> <Box asChild>
<span className="dashed" title={title}> <li>
{children} <Text className="dashed" title={title} size="2">
</span> {children}
</li> </Text>
</li>
</Box>
); );
}; };
@@ -20,15 +22,30 @@ type PropertyListProps = {
children: ReactNode; children: ReactNode;
}; };
/**
* PropertyList displays a labeled list of items (not key-value pairs).
* Uses DataList.Item for the label, and renders children as a bulleted list.
*/
const PropertyList: FunctionComponent<PropertyListProps> & { const PropertyList: FunctionComponent<PropertyListProps> & {
Item: typeof PropertyListItem; Item: typeof PropertyListItem;
} = ({ title, children }) => { } = ({ title, children }) => {
return ( return (
<Property title={title}> <DataList.Item>
<ul key={2} className="list-disc"> <DataList.Label>{title}</DataList.Label>
{children} <DataList.Value>
</ul> <Box asChild>
</Property> <ul
style={{
listStyleType: "disc",
paddingLeft: "1.25rem",
margin: 0,
}}
>
{children}
</ul>
</Box>
</DataList.Value>
</DataList.Item>
); );
}; };

View File

@@ -0,0 +1,40 @@
"use client";
import { useTheme } from "next-themes";
import { IconButton } from "@radix-ui/themes";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react";
export const ThemeToggle = () => {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by only rendering after mount
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
return (
<IconButton
size="3"
variant="ghost"
onClick={toggleTheme}
aria-label="Toggle theme"
title={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
>
{theme === "light" ? (
<MoonIcon width="18" height="18" />
) : (
<SunIcon width="18" height="18" />
)}
</IconButton>
);
};

View File

@@ -1,24 +1,11 @@
import { useForm } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import { Fragment, useState } from "react"; import { useState } from "react";
import { onPromise, preventDefault } from "@/helpers"; import { onPromise, preventDefault } from "@/helpers";
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/types"; import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/types";
import { TargetTypeEnum } from "@/schema"; import { TargetTypeEnum } from "@/schema";
import { import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
CheckIcon, import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes";
ChevronUpDownIcon,
LockClosedIcon,
MagnifyingGlassIcon,
ArrowPathIcon,
} from "@heroicons/react/20/solid";
import {
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
Transition,
} from "@headlessui/react";
import { cn } from "@/lib/utils";
import type { Maybe } from "true-myth"; import type { Maybe } from "true-myth";
import { placeholders } from "@/constants"; import { placeholders } from "@/constants";
@@ -54,7 +41,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
onChange, onChange,
detectedType, detectedType,
}: LookupInputProps) => { }: LookupInputProps) => {
const { register, handleSubmit, getValues } = useForm<SubmitProps>({ const { register, handleSubmit, getValues, control } = useForm<SubmitProps>({
defaultValues: { defaultValues: {
target: "", target: "",
// Not used at this time. // Not used at this time.
@@ -115,200 +102,143 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
return result.success ? result.data : null; return result.success ? result.data : null;
} }
const searchIcon = (
<>
<button
type="submit"
className={cn({
"absolute inset-y-0 left-0 flex items-center pl-3": true,
"pointer-events-none": isLoading,
})}
>
{isLoading ? (
<ArrowPathIcon
className="h-5 w-5 animate-spin text-zinc-400"
aria-hidden="true"
/>
) : (
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
)}
</button>
</>
);
const searchInput = (
<input
className={cn(
"block w-full rounded-l-md border border-transparent lg:py-4.5",
"bg-zinc-700 py-2 pr-1.5 pl-10 text-sm placeholder-zinc-400 placeholder:translate-y-2 focus:text-zinc-200",
"focus:outline-hidden sm:text-sm md:py-3 md:text-base lg:text-lg"
)}
disabled={isLoading}
placeholder={placeholders[selected]}
type="search"
{...register("target", {
required: true,
onChange: () => {
if (onChange != undefined)
void onChange({
target: getValues("target"),
// dropdown target will be pulled from state anyways, so no need to provide it here
targetType: retrieveTargetType(null),
});
},
})}
/>
);
const dropdown = (
<Listbox
value={selected}
onChange={(value) => {
setSelected(value);
if (onChange != undefined)
void onChange({
target: getValues("target"),
// we provide the value as the state will not have updated yet for this context
targetType: retrieveTargetType(value),
});
}}
disabled={isLoading}
>
<div className="relative">
<ListboxButton
className={cn(
"relative h-full w-full cursor-default rounded-r-lg bg-zinc-700 py-2 pr-10 pl-1 text-right whitespace-nowrap",
"text-xs focus:outline-hidden focus-visible:border-indigo-500 sm:text-sm md:text-base lg:text-lg",
"focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300"
)}
>
{/* Fetch special text for 'auto' mode, otherwise just use the options. */}
<span className="block">
{selected == "auto" ? (
// If the detected type was provided, then notate which in parentheses. Compact object naming might be better in the future.
detectedType.isJust ? (
<>
Auto (
<span className="animate-pulse">
{targetShortNames[detectedType.value]}
</span>
)
</>
) : (
objectNames["auto"]
)
) : (
<>
<LockClosedIcon
className="mr-2.5 mb-1 inline h-4 w-4 animate-pulse text-zinc-500"
aria-hidden
/>
{objectNames[selected]}
</>
)}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-zinc-200" aria-hidden="true" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
className={cn(
"scrollbar-thin absolute right-0 mt-1 max-h-60 min-w-full overflow-auto rounded-md bg-zinc-700 py-1",
"text-zinc-200 shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
)}
>
{Object.entries(objectNames).map(([key, value]) => (
<ListboxOption
key={key}
className={({ focus }) =>
cn(
"relative cursor-default py-2 pr-4 pl-10 select-none",
focus ? "bg-zinc-800 text-zinc-300" : null
)
}
value={key}
>
{({ selected }) => (
<>
<span
className={cn(
"block text-right text-xs whitespace-nowrap md:text-sm lg:text-base",
selected ? "font-medium" : null
)}
>
{value}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-500">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : (
<button
onClick={(e) => {
e.preventDefault();
// TODO: Show Help Explanation
}}
className="absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-bold opacity-20 hover:animate-pulse"
>
?
</button>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
);
return ( return (
<form <form
className="pb-3" className="pb-2.5"
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault} onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
> >
<div className="col"> <Flex direction="column" gap="3">
<label htmlFor="search" className="sr-only"> <label htmlFor="search" className="sr-only">
Search Search
</label> </label>
<div className="relative flex"> <Flex gap="0" style={{ position: "relative" }}>
{searchIcon} <TextField.Root
{searchInput} size="3"
{dropdown} placeholder={placeholders[selected]}
</div> disabled={isLoading}
</div> {...register("target", {
<div className="col"> required: true,
<div className="flex flex-wrap pt-3 pb-1 text-sm"> onChange: () => {
<div className="whitespace-nowrap"> if (onChange != undefined)
<input void onChange({
className="mr-1 ml-2 whitespace-nowrap text-zinc-800 accent-blue-700" target: getValues("target"),
type="checkbox" targetType: retrieveTargetType(null),
{...register("requestJSContact")} });
/> },
<label className="text-zinc-300" htmlFor="requestJSContact"> })}
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
border: "1px solid var(--gray-7)",
borderRight: "none",
boxShadow: "none",
flex: 1,
}}
>
<TextField.Slot side="left">
<IconButton
size="1"
variant="ghost"
type="submit"
disabled={isLoading}
tabIndex={-1}
style={{ cursor: isLoading ? "not-allowed" : "pointer" }}
>
{isLoading ? (
<ReloadIcon className="animate-spin" width="16" height="16" />
) : (
<MagnifyingGlassIcon width="16" height="16" />
)}
</IconButton>
</TextField.Slot>
</TextField.Root>
<Select.Root
value={selected}
onValueChange={(value) => {
setSelected(value as SimplifiedTargetType | "auto");
if (onChange != undefined)
void onChange({
target: getValues("target"),
targetType: retrieveTargetType(value),
});
}}
disabled={isLoading}
size="3"
>
<Select.Trigger
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
minWidth: "150px",
}}
>
{selected == "auto" ? (
detectedType.isJust ? (
<Text>Auto ({targetShortNames[detectedType.value]})</Text>
) : (
objectNames["auto"]
)
) : (
<Flex align="center" gap="2">
<LockClosedIcon width="14" height="14" />
{objectNames[selected]}
</Flex>
)}
</Select.Trigger>
<Select.Content position="popper">
{Object.entries(objectNames).map(([key, value]) => (
<Select.Item key={key} value={key}>
<Flex
align="center"
justify="between"
gap="2"
style={{ width: "100%" }}
>
{value}
</Flex>
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Flex>
<Flex pl="3" gapX="5" gapY="2" wrap="wrap">
<Flex asChild align="center" gap="2">
<Text as="label" size="2">
<Controller
name="requestJSContact"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
Request JSContact Request JSContact
</label> </Text>
</div> </Flex>
<div className="whitespace-nowrap"> <Flex asChild align="center" gap="2">
<input <Text as="label" size="2">
className="mr-1 ml-2 bg-zinc-500 text-inherit accent-blue-700" <Controller
type="checkbox" name="followReferral"
{...register("followReferral")} control={control}
/> render={({ field }) => (
<label className="text-zinc-300" htmlFor="followReferral"> <Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
Follow referral to registrar&apos;s RDAP record Follow referral to registrar&apos;s RDAP record
</label> </Text>
</div> </Flex>
</div> </Flex>
</div> </Flex>
</form> </form>
); );
}; };

View File

@@ -5,6 +5,7 @@ import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property"; import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList"; import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard"; import AbstractCard from "@/components/common/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type AutnumCardProps = { export type AutnumCardProps = {
data: AutonomousNumber; data: AutonomousNumber;
@@ -22,14 +23,13 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
data={data} data={data}
url={url} url={url}
header={ header={
<> <Flex gap="2" align="center" wrap="wrap">
<span className="font-mono tracking-tighter">AUTONOMOUS SYSTEM</span> <Text size="5">{asnRange}</Text>
<span className="font-mono tracking-wide">{asnRange}</span> <Badge color="gray">AUTONOMOUS SYSTEM</Badge>
<span className="whitespace-nowrap">({data.handle})</span> </Flex>
</>
} }
> >
<dl> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="Name">{data.name}</Property> <Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property> <Property title="Handle">{data.handle}</Property>
<Property title="ASN Range"> <Property title="ASN Range">
@@ -49,7 +49,7 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
</PropertyList.Item> </PropertyList.Item>
))} ))}
</PropertyList> </PropertyList>
</dl> </DataList.Root>
</AbstractCard> </AbstractCard>
); );
}; };

View File

@@ -6,6 +6,7 @@ import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property"; import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList"; import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard"; import AbstractCard from "@/components/common/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type DomainProps = { export type DomainProps = {
data: Domain; data: Domain;
@@ -18,16 +19,13 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
data={data} data={data}
url={url} url={url}
header={ header={
<> <Flex gap="2" align="center" wrap="wrap">
<span className="font-mono tracking-tighter">DOMAIN</span> <Text size="5">{data.ldhName ?? data.unicodeName}</Text>
<span className="font-mono tracking-wide"> <Badge color="gray">DOMAIN</Badge>
{data.ldhName ?? data.unicodeName} </Flex>
</span>
<span className="whitespace-nowrap">({data.handle})</span>
</>
} }
> >
<dl> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{data.unicodeName != undefined ? ( {data.unicodeName != undefined ? (
<Property title="Unicode Name">{data.unicodeName}</Property> <Property title="Unicode Name">{data.unicodeName}</Property>
) : null} ) : null}
@@ -45,7 +43,7 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
</PropertyList.Item> </PropertyList.Item>
))} ))}
</PropertyList> </PropertyList>
</dl> </DataList.Root>
</AbstractCard> </AbstractCard>
); );
}; };

View File

@@ -4,6 +4,7 @@ import type { Entity } from "@/types";
import Property from "@/components/common/Property"; import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList"; import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard"; import AbstractCard from "@/components/common/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
export type EntityCardProps = { export type EntityCardProps = {
data: Entity; data: Entity;
@@ -16,15 +17,13 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
data={data} data={data}
url={url} url={url}
header={ header={
<> <Flex gap="2" align="center" wrap="wrap">
<span className="font-mono tracking-tighter">ENTITY</span> <Text size="5">{data.handle || data.roles.join(", ")}</Text>
<span className="font-mono tracking-wide"> <Badge color="gray">ENTITY</Badge>
{data.handle || data.roles.join(", ")} </Flex>
</span>
</>
} }
> >
<dl> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
{data.handle && <Property title="Handle">{data.handle}</Property>} {data.handle && <Property title="Handle">{data.handle}</Property>}
<PropertyList title="Roles"> <PropertyList title="Roles">
{data.roles.map((role, index) => ( {data.roles.map((role, index) => (
@@ -42,7 +41,7 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
))} ))}
</PropertyList> </PropertyList>
)} )}
</dl> </DataList.Root>
</AbstractCard> </AbstractCard>
); );
}; };

View File

@@ -1,26 +1,42 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import type { Event } from "@/types"; import type { Event } from "@/types";
import { Fragment } from "react";
import DynamicDate from "@/components/common/DynamicDate"; import DynamicDate from "@/components/common/DynamicDate";
import { Table, Text } from "@radix-ui/themes";
export type EventsProps = { export type EventsProps = {
data: Event[]; data: Event[];
}; };
const Events: FunctionComponent<EventsProps> = ({ data }) => { const Events: FunctionComponent<EventsProps> = ({ data }) => {
return ( return (
<dl> <Table.Root size="1" variant="surface">
{data.map(({ eventAction, eventDate, eventActor }, index) => { <Table.Header>
return ( <Table.Row>
<Fragment key={index}> <Table.ColumnHeaderCell>Event</Table.ColumnHeaderCell>
<dt className="font-weight-bolder">{eventAction}:</dt> <Table.ColumnHeaderCell>Date</Table.ColumnHeaderCell>
<dd> <Table.ColumnHeaderCell>Actor</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{data.map(({ eventAction, eventDate, eventActor }, index) => (
<Table.Row key={index}>
<Table.Cell>
<Text size="2" weight="medium">
{eventAction}
</Text>
</Table.Cell>
<Table.Cell>
<DynamicDate value={new Date(eventDate)} /> <DynamicDate value={new Date(eventDate)} />
{eventActor != null ? ` (by ${eventActor})` : null} </Table.Cell>
</dd> <Table.Cell>
</Fragment> <Text size="2" color="gray">
); {eventActor ?? "—"}
})} </Text>
</dl> </Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
); );
}; };

View File

@@ -35,12 +35,6 @@ const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) =>
</AbstractCard> </AbstractCard>
); );
} }
// const title: string = (data.unicodeName ?? data.ldhName ?? data.handle)?.toUpperCase() ?? "Response";
// return <div className="card">
// <div className="card-header">{title}</div>
// {objectFragment}
// </div>
}; };
export default Generic; export default Generic;

View File

@@ -5,6 +5,7 @@ import Events from "@/components/lookup/Events";
import Property from "@/components/common/Property"; import Property from "@/components/common/Property";
import PropertyList from "@/components/common/PropertyList"; import PropertyList from "@/components/common/PropertyList";
import AbstractCard from "@/components/common/AbstractCard"; import AbstractCard from "@/components/common/AbstractCard";
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
export type IPCardProps = { export type IPCardProps = {
data: IpNetwork; data: IpNetwork;
@@ -17,17 +18,15 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
data={data} data={data}
url={url} url={url}
header={ header={
<> <Flex gap="2" align="center" wrap="wrap">
<span className="font-mono tracking-tighter">IP NETWORK</span> <Text size="5">
<span className="font-mono tracking-wide"> {data.startAddress} - {data.endAddress}
{data.startAddress} </Text>
{data.startAddress !== data.endAddress && ` - ${data.endAddress}`} <Badge color="gray">IP NETWORK</Badge>
</span> </Flex>
<span className="whitespace-nowrap">({data.handle})</span>
</>
} }
> >
<dl> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="Name">{data.name}</Property> <Property title="Name">{data.name}</Property>
<Property title="Handle">{data.handle}</Property> <Property title="Handle">{data.handle}</Property>
<Property title="IP Version">{data.ipVersion.toUpperCase()}</Property> <Property title="IP Version">{data.ipVersion.toUpperCase()}</Property>
@@ -48,7 +47,7 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
</PropertyList.Item> </PropertyList.Item>
))} ))}
</PropertyList> </PropertyList>
</dl> </DataList.Root>
</AbstractCard> </AbstractCard>
); );
}; };

View File

@@ -3,6 +3,7 @@ import React from "react";
import type { Nameserver } from "@/types"; import type { Nameserver } from "@/types";
import Property from "@/components/common/Property"; import Property from "@/components/common/Property";
import AbstractCard from "@/components/common/AbstractCard"; import AbstractCard from "@/components/common/AbstractCard";
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
export type NameserverCardProps = { export type NameserverCardProps = {
data: Nameserver; data: Nameserver;
@@ -18,15 +19,15 @@ const NameserverCard: FunctionComponent<NameserverCardProps> = ({
data={data} data={data}
url={url} url={url}
header={ header={
<> <Flex gap="2" align="center" wrap="wrap">
<span className="font-mono tracking-tighter">NAMESERVER</span> <Text size="5">{data.ldhName}</Text>
<span className="font-mono tracking-wide">{data.ldhName}</span> <Badge color="gray">NAMESERVER</Badge>
</> </Flex>
} }
> >
<dl> <DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
<Property title="LDH Name">{data.ldhName}</Property> <Property title="LDH Name">{data.ldhName}</Property>
</dl> </DataList.Root>
</AbstractCard> </AbstractCard>
); );
}; };

View File

@@ -1,12 +1,21 @@
import { type AppType } from "next/dist/shared/lib/utils"; import { type AppType } from "next/dist/shared/lib/utils";
import { ThemeProvider } from "next-themes";
import { Theme } from "@radix-ui/themes";
import "@fontsource-variable/inter"; import "@fontsource-variable/inter";
import "@fontsource/ibm-plex-mono/400.css"; import "@fontsource/ibm-plex-mono/400.css";
import "@radix-ui/themes/styles.css";
import "../styles/globals.css"; import "../styles/globals.css";
const MyApp: AppType = ({ Component, pageProps }) => { const MyApp: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />; return (
<ThemeProvider attribute="class" defaultTheme="system">
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
<Component {...pageProps} />
</Theme>
</ThemeProvider>
);
}; };
export default MyApp; export default MyApp;

View File

@@ -6,8 +6,10 @@ import type { MetaParsedGeneric } from "@/hooks/useLookup";
import useLookup from "@/hooks/useLookup"; import useLookup from "@/hooks/useLookup";
import LookupInput from "@/components/form/LookupInput"; import LookupInput from "@/components/form/LookupInput";
import ErrorCard from "@/components/common/ErrorCard"; import ErrorCard from "@/components/common/ErrorCard";
import { ThemeToggle } from "@/components/common/ThemeToggle";
import { Maybe } from "true-myth"; import { Maybe } from "true-myth";
import type { TargetType } from "@/types"; import type { TargetType } from "@/types";
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
const Index: NextPage = () => { const Index: NextPage = () => {
const { error, setTarget, setTargetType, submit, getType } = useLookup(); const { error, setTarget, setTargetType, submit, getType } = useLookup();
@@ -37,16 +39,30 @@ const Index: NextPage = () => {
content="xevion, rdap, whois, rdap, domain name, dns, ip address" content="xevion, rdap, whois, rdap, domain name, dns, ip address"
/> />
</Head> </Head>
<nav className="bg-zinc-850 px-5 py-4 shadow-xs"> <Flex
<span className="text-xl font-medium text-white" style={{ fontSize: "larger" }}> asChild
<a href="https://github.com/Xevion/rdap">rdap</a> justify="between"
<a href={"https://xevion.dev"} className="text-zinc-400 hover:animate-pulse"> align="center"
.xevion.dev px="5"
</a> py="4"
</span> style={{
</nav> borderBottom: "1px solid var(--gray-a5)",
<div className="mx-auto max-w-screen-sm px-5 lg:max-w-screen-md xl:max-w-screen-lg"> }}
<div className="dark container mx-auto w-full py-6 md:py-12"> >
<nav>
<Text size="5" weight="medium">
<Link href="https://github.com/Xevion/rdap" color="gray" highContrast>
rdap
</Link>
<Link href="https://xevion.dev" color="gray">
.xevion.dev
</Link>
</Text>
<ThemeToggle />
</nav>
</Flex>
<Container size="3" px="5">
<Section size="2">
<LookupInput <LookupInput
isLoading={isLoading} isLoading={isLoading}
detectedType={detectedType} detectedType={detectedType}
@@ -83,8 +99,8 @@ const Index: NextPage = () => {
{response.isJust ? ( {response.isJust ? (
<Generic url={response.value.url} data={response.value.data} /> <Generic url={response.value.url} data={response.value.data} />
) : null} ) : null}
</div> </Section>
</div> </Container>
</> </>
); );
}; };

View File

@@ -7,31 +7,27 @@
--font-mono: --font-mono:
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace; "Courier New", monospace;
--color-zinc-850: #1d1d20;
}
dd {
margin: 0.5em 0 1em 2em;
} }
/* Utility classes */
.dashed { .dashed {
border-bottom: 1px dashed silver; border-bottom: 1px dashed var(--gray-a6);
}
body {
color-scheme: dark;
@apply bg-zinc-900 font-sans text-white;
}
dd,
dl {
white-space: nowrap;
}
dl {
margin: 0;
} }
.scrollbar-thin { .scrollbar-thin {
scrollbar-width: thin; scrollbar-width: thin;
} }
/* Keep animate-spin for loading states */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}