diff --git a/package.json b/package.json index 53f5d35..e973720 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource/ibm-plex-mono": "^5.2.7", + "@mantine/hooks": "^8.3.5", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.2.1", "@swc/helpers": "^0.5.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d550dff..b6961ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fontsource/ibm-plex-mono': specifier: ^5.2.7 version: 5.2.7 + '@mantine/hooks': + specifier: ^8.3.5 + version: 8.3.5(react@19.2.0) '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.2.0) @@ -602,6 +605,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mantine/hooks@8.3.5': + resolution: {integrity: sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==} + peerDependencies: + react: ^18.x || ^19.x + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4349,6 +4357,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mantine/hooks@8.3.5(react@19.2.0)': + dependencies: + react: 19.2.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.6.0 diff --git a/src/components/form/LookupInput.tsx b/src/components/form/LookupInput.tsx deleted file mode 100644 index 59cb1e2..0000000 --- a/src/components/form/LookupInput.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { useForm, Controller } from "react-hook-form"; -import type { FunctionComponent } from "react"; -import { useState } from "react"; -import { onPromise, preventDefault } from "@/lib/utils"; -import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas"; -import { TargetTypeEnum } from "@/rdap/schemas"; -import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons"; -import { TextField, Select, Flex, Checkbox, Text, IconButton } from "@radix-ui/themes"; -import type { Maybe } from "true-myth"; -import { placeholders } from "@/rdap/constants"; - -/** - * Props for the LookupInput component. - */ -type LookupInputProps = { - isLoading?: boolean; - /** - * Callback function called when a type of registry is detected when a user changes their input. - * @param type - The detected type of registry. - * @returns A promise. - */ - onRegistry?: (type: TargetType) => Promise; - /** - * Callback function called when a user hits submit. - * @param props - The submit props. - * @returns A promise. - */ - onSubmit?: (props: SubmitProps) => Promise; - /** - * Callback function called when a user changes their input (text search) or explicitly changes the type of search. - * @param target - The target object containing the search target and target type. - * @returns Nothing. - */ - onChange?: (target: { target: string; targetType: TargetType | null }) => Promise; - detectedType: Maybe; -}; - -const LookupInput: FunctionComponent = ({ - isLoading, - onSubmit, - onChange, - detectedType, -}: LookupInputProps) => { - const { register, handleSubmit, getValues, control } = useForm({ - defaultValues: { - target: "", - // Not used at this time. - followReferral: false, - requestJSContact: false, - }, - }); - - /** - * A mapping of available (simple) target types to their long-form human-readable names. - */ - const objectNames: Record = { - auto: "Autodetect", - domain: "Domain", - ip: "IP/CIDR", // IPv4/IPv6 are combined into this option - tld: "TLD", - autnum: "AS Number", - entity: "Entity Handle", - registrar: "Registrar", - url: "URL", - json: "JSON", - }; - - /** - * Mapping of precise target types to their simplified short-form names. - */ - const targetShortNames: Record = { - domain: "Domain", - tld: "TLD", - ip4: "IPv4", - ip6: "IPv6", - autnum: "ASN", - entity: "Entity", - registrar: "Registrar", - url: "URL", - json: "JSON", - }; - - /** - * Represents the selected value in the LookupInput component. - */ - const [selected, setSelected] = useState("auto"); - - /** - * Retrieves the target type based on the provided value. - * @param value - The value to retrieve the target type for. - * @returns The target type as ObjectType or null. - */ - function retrieveTargetType(value?: string | null): TargetType | null { - // If the value is null and the selected value is null, return null. - if (value == null) value = selected; - - // 'auto' means 'do whatever' so we return null. - if (value == "auto") return null; - - // Validate the value is a valid TargetType - const result = TargetTypeEnum.safeParse(value); - return result.success ? result.data : null; - } - - return ( -
- - - - { - if (onChange != undefined) - void onChange({ - target: getValues("target"), - targetType: retrieveTargetType(null), - }); - }, - })} - style={{ - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - border: "1px solid var(--gray-7)", - borderRight: "none", - boxShadow: "none", - flex: 1, - }} - > - - - {isLoading ? ( - - ) : ( - - )} - - - - - { - setSelected(value as SimplifiedTargetType | "auto"); - - if (onChange != undefined) - void onChange({ - target: getValues("target"), - targetType: retrieveTargetType(value), - }); - }} - disabled={isLoading} - size="3" - > - - {selected == "auto" ? ( - detectedType.isJust ? ( - Auto ({targetShortNames[detectedType.value]}) - ) : ( - objectNames["auto"] - ) - ) : ( - - - {objectNames[selected]} - - )} - - - - {Object.entries(objectNames).map(([key, value]) => ( - - - {value} - - - ))} - - - - - - - - ( - - )} - /> - Request JSContact - - - - - ( - - )} - /> - Follow referral to registrar's RDAP record - - - - -
- ); -}; - -export default LookupInput; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8965047..04fffd6 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -8,12 +8,10 @@ import LookupInput from "@/rdap/components/LookupInput"; import ErrorCard from "@/components/ErrorCard"; import { ThemeToggle } from "@/components/ThemeToggle"; import { Maybe } from "true-myth"; -import type { TargetType } from "@/rdap/schemas"; import { Flex, Container, Section, Text, Link } from "@radix-ui/themes"; const Index: NextPage = () => { - const { error, setTarget, setTargetType, submit, getType } = useLookup(); - const [detectedType, setDetectedType] = useState>(Maybe.nothing()); + const { error, setTarget, setTargetType, submit, currentType } = useLookup(); const [response, setResponse] = useState>(Maybe.nothing()); const [isLoading, setLoading] = useState(false); @@ -65,20 +63,10 @@ const Index: NextPage = () => {
{ + detectedType={currentType} + onChange={({ target, targetType }) => { setTarget(target); setTargetType(targetType); - - // Only run autodetection when in autodetect mode (targetType is null) - if (targetType === null) { - const detectResult = await getType(target); - if (detectResult.isOk) { - setDetectedType(Maybe.just(detectResult.value)); - } else { - setDetectedType(Maybe.nothing()); - } - } }} onSubmit={async function (props) { try { diff --git a/src/rdap/components/LookupInput.tsx b/src/rdap/components/LookupInput.tsx index 59cb1e2..7399fd1 100644 --- a/src/rdap/components/LookupInput.tsx +++ b/src/rdap/components/LookupInput.tsx @@ -31,7 +31,7 @@ type LookupInputProps = { * @param target - The target object containing the search target and target type. * @returns Nothing. */ - onChange?: (target: { target: string; targetType: TargetType | null }) => Promise; + onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise; detectedType: Maybe; }; @@ -113,6 +113,7 @@ const LookupInput: FunctionComponent = ({ { const [error, setError] = useState(null); const [target, setTarget] = useState(""); + const [debouncedTarget] = useDebouncedValue(target, 150); const [uriType, setUriType] = useState>(Maybe.nothing()); // Used by a callback on LookupInput to forcibly set the type of the lookup. @@ -39,13 +41,20 @@ const useLookup = (warningHandler?: WarningHandler) => { return getType(target, getRegistry); }, []); - useCallback(async () => { - if (currentType != null) return Maybe.just(currentType); - const uri: Maybe = (await getTypeEasy(target)).mapOr(Maybe.nothing(), (type) => - Maybe.just(type) - ); - setUriType(uri); - }, [target, currentType, getTypeEasy]); + useEffect(() => { + const detectType = async () => { + if (currentType != null || debouncedTarget.length === 0) return; + + const detectedType = await getTypeEasy(debouncedTarget); + if (detectedType.isOk) { + setUriType(Maybe.just(detectedType.value)); + } else { + setUriType(Maybe.nothing()); + } + }; + + detectType().catch(console.error); + }, [debouncedTarget, currentType, getTypeEasy]); useEffect(() => { const preload = async () => { @@ -67,7 +76,7 @@ const useLookup = (warningHandler?: WarningHandler) => { }; preload().catch(console.error); - }, [target, uriType, warningHandler]); + }, [uriType, warningHandler]); async function submitInternal( target: string @@ -160,7 +169,7 @@ const useLookup = (warningHandler?: WarningHandler) => { case "url": { const response = await fetch(target); - if (response.status != 200) + if (response.status !== 200) return Result.err( new Error( `The URL provided returned a non-200 status code: ${response.status}.` @@ -178,10 +187,14 @@ const useLookup = (warningHandler?: WarningHandler) => { return Result.err(new Error("No schema was able to parse the response.")); } case "json": { - const data = JSON.parse(target); - for (const schema of schemas) { - const result = schema.safeParse(data); - if (result.success) return Result.ok({ data: result.data, url: "" }); + try { + const data = JSON.parse(target); + for (const schema of schemas) { + const result = schema.safeParse(data); + if (result.success) return Result.ok({ data: result.data, url: "" }); + } + } catch (e) { + return Result.err(new Error("Invalid JSON format", { cause: e })); } } case "registrar": { diff --git a/src/rdap/services/rdap-api.ts b/src/rdap/services/rdap-api.ts index 0c705ef..c88d00f 100644 --- a/src/rdap/services/rdap-api.ts +++ b/src/rdap/services/rdap-api.ts @@ -7,7 +7,7 @@ import { Result } from "true-myth"; export async function getAndParse(url: string, schema: ZodSchema): Promise> { const response = await fetch(url); - if (response.status == 200) { + if (response.status === 200) { const result = schema.safeParse(await response.json()); if (result.success === false) { diff --git a/src/rdap/services/url-resolver.ts b/src/rdap/services/url-resolver.ts index 7998f7f..8bbffe1 100644 --- a/src/rdap/services/url-resolver.ts +++ b/src/rdap/services/url-resolver.ts @@ -25,7 +25,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st throw new Error(`No matching domain found.`); case "ip4": { // Extract the IP address without CIDR suffix for matching - const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget; + const [ipAddress] = lookupTarget.split("/"); + if (!ipAddress) throw new Error(`Invalid IPv4 format: ${lookupTarget}`); for (const bootstrapItem of bootstrap.services) { // bootstrapItem[0] contains CIDR ranges like ["1.0.0.0/8", "2.0.0.0/8"] if (bootstrapItem[0].some((cidr) => ipv4InCIDR(ipAddress, cidr))) { @@ -37,7 +38,8 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st } case "ip6": { // Extract the IP address without CIDR suffix for matching - const ipAddress = lookupTarget.split("/")[0] ?? lookupTarget; + const [ipAddress] = lookupTarget.split("/"); + if (!ipAddress) throw new Error(`Invalid IPv6 format: ${lookupTarget}`); for (const bootstrapItem of bootstrap.services) { // bootstrapItem[0] contains CIDR ranges like ["2001:0200::/23", "2001:0400::/23"] if (bootstrapItem[0].some((cidr) => ipv6InCIDR(ipAddress, cidr))) { diff --git a/src/rdap/utils.ts b/src/rdap/utils.ts index 92276ce..9cb64cb 100644 --- a/src/rdap/utils.ts +++ b/src/rdap/utils.ts @@ -11,10 +11,7 @@ export function domainMatch(tld: string, domain: string): boolean { // return the first HTTPS url, or the first URL export function getBestURL(urls: [string, ...string[]]): string { - urls.forEach((url) => { - if (url.startsWith("https://")) return url; - }); - return urls[0]; + return urls.find((url) => url.startsWith("https://")) ?? urls[0]; } type ValidatorArgs = {