diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx new file mode 100644 index 0000000..824d81c --- /dev/null +++ b/src/components/ShareButton.tsx @@ -0,0 +1,66 @@ +import type { FunctionComponent } from "react"; +import { useState, useCallback, useEffect } from "react"; +import { Link2Icon, CheckIcon } from "@radix-ui/react-icons"; +import { IconButton, Tooltip } from "@radix-ui/themes"; + +export type ShareButtonProps = { + /** + * The URL to copy when the button is clicked + */ + url: string; + /** + * Button size (1, 2, or 3) + */ + size?: "1" | "2" | "3"; + /** + * Button variant + */ + variant?: "classic" | "solid" | "soft" | "surface" | "outline" | "ghost"; +}; + +const ShareButton: FunctionComponent = ({ + url, + size = "1", + variant = "ghost", +}) => { + const [copied, setCopied] = useState(false); + + // Reset copied state after 2 seconds + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + } + }, [copied]); + + const handleShare = useCallback(() => { + navigator.clipboard.writeText(url).then( + () => { + setCopied(true); + }, + (err) => { + if (err instanceof Error) { + console.error(`Failed to copy URL to clipboard: ${err.toString()}`); + } else { + console.error("Failed to copy URL to clipboard."); + } + } + ); + }, [url]); + + return ( + + + {copied ? : } + + + ); +}; + +export default ShareButton; diff --git a/src/lib/url-utils.test.ts b/src/lib/url-utils.test.ts new file mode 100644 index 0000000..e6ce021 --- /dev/null +++ b/src/lib/url-utils.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "./url-utils"; + +describe("URL Utilities", () => { + describe("serializeQueryToUrl", () => { + it("should serialize query without type (auto-detection)", () => { + const result = serializeQueryToUrl("example.com"); + expect(result).toBe("?query=example.com"); + }); + + it("should serialize query with manually selected type", () => { + const result = serializeQueryToUrl("example.com", "domain"); + expect(result).toBe("?query=example.com&type=domain"); + }); + + it("should handle null type as auto-detection", () => { + const result = serializeQueryToUrl("8.8.8.8", null); + expect(result).toBe("?query=8.8.8.8"); + }); + + it("should handle empty query", () => { + const result = serializeQueryToUrl(""); + expect(result).toBe(""); + }); + + it("should URL-encode special characters", () => { + const result = serializeQueryToUrl("test value with spaces"); + expect(result).toBe("?query=test+value+with+spaces"); + }); + }); + + describe("deserializeUrlToQuery", () => { + it("should deserialize query without type", () => { + const params = new URLSearchParams("?query=example.com"); + const result = deserializeUrlToQuery(params); + expect(result).toEqual({ + query: "example.com", + type: undefined, + }); + }); + + it("should deserialize query with valid type", () => { + const params = new URLSearchParams("?query=example.com&type=domain"); + const result = deserializeUrlToQuery(params); + expect(result).toEqual({ + query: "example.com", + type: "domain", + }); + }); + + it("should ignore invalid type parameter", () => { + const params = new URLSearchParams("?query=example.com&type=invalid"); + const result = deserializeUrlToQuery(params); + expect(result).toEqual({ + query: "example.com", + type: undefined, + }); + }); + + it("should return null for missing query", () => { + const params = new URLSearchParams("?type=domain"); + const result = deserializeUrlToQuery(params); + expect(result).toBeNull(); + }); + + it("should return null for empty params", () => { + const params = new URLSearchParams(""); + const result = deserializeUrlToQuery(params); + expect(result).toBeNull(); + }); + + it("should handle all valid target types", () => { + const types = [ + "autnum", + "domain", + "ip4", + "ip6", + "entity", + "url", + "tld", + "registrar", + "json", + ]; + for (const type of types) { + const params = new URLSearchParams(`?query=test&type=${type}`); + const result = deserializeUrlToQuery(params); + expect(result?.type).toBe(type); + } + }); + }); + + describe("buildShareableUrl", () => { + it("should build complete shareable URL without type", () => { + const result = buildShareableUrl("https://rdap.xevion.dev", "example.com"); + expect(result).toBe("https://rdap.xevion.dev?query=example.com"); + }); + + it("should build complete shareable URL with type", () => { + const result = buildShareableUrl("https://rdap.xevion.dev", "example.com", "domain"); + expect(result).toBe("https://rdap.xevion.dev?query=example.com&type=domain"); + }); + + it("should handle base URL with trailing slash", () => { + const result = buildShareableUrl("https://rdap.xevion.dev/", "8.8.8.8", "ip4"); + expect(result).toBe("https://rdap.xevion.dev/?query=8.8.8.8&type=ip4"); + }); + }); +}); diff --git a/src/lib/url-utils.ts b/src/lib/url-utils.ts new file mode 100644 index 0000000..b85a2d7 --- /dev/null +++ b/src/lib/url-utils.ts @@ -0,0 +1,85 @@ +import type { TargetType } from "@/rdap/schemas"; +import { TargetTypeEnum } from "@/rdap/schemas"; + +/** + * Represents the state that can be serialized to/from URL parameters. + */ +export type QueryUrlState = { + query: string; + type?: TargetType; // Only present if manually selected (not auto-detected) +}; + +/** + * Serializes query state to URL query parameters. + * + * @param query - The lookup target (domain, IP, ASN, etc.) + * @param type - The manually selected type (undefined for auto-detection) + * @returns URL query parameters string (e.g., "?query=example.com&type=domain") + */ +export function serializeQueryToUrl(query: string, type?: TargetType | null): string { + const params = new URLSearchParams(); + + if (query) { + params.set("query", query); + } + + // Only include type if it was manually selected (not auto-detected) + if (type != null) { + params.set("type", type); + } + + const paramString = params.toString(); + return paramString ? `?${paramString}` : ""; +} + +/** + * Deserializes URL query parameters to query state. + * Validates the type parameter against the TargetTypeEnum schema. + * + * @param searchParams - URLSearchParams object from the router + * @returns QueryUrlState object with validated query and optional type + */ +export function deserializeUrlToQuery(searchParams: URLSearchParams): QueryUrlState | null { + const query = searchParams.get("query"); + const typeParam = searchParams.get("type"); + + // Query is required + if (!query) { + return null; + } + + let type: TargetType | undefined; + + // Validate type parameter if present + if (typeParam) { + const result = TargetTypeEnum.safeParse(typeParam); + if (result.success) { + type = result.data; + } else { + // Invalid type parameter - ignore it and use auto-detection + console.warn(`Invalid type parameter: ${typeParam}. Using auto-detection.`); + } + } + + return { + query, + type, + }; +} + +/** + * Builds a shareable URL for the current query. + * + * @param baseUrl - The base URL (e.g., window.location.origin + window.location.pathname) + * @param query - The lookup target + * @param type - The manually selected type (null/undefined for auto-detection) + * @returns Complete shareable URL + */ +export function buildShareableUrl( + baseUrl: string, + query: string, + type?: TargetType | null +): string { + const queryString = serializeQueryToUrl(query, type); + return `${baseUrl}${queryString}`; +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 33a19e2..741d452 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,6 +1,7 @@ import { type NextPage } from "next"; import Head from "next/head"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/router"; import Generic from "@/rdap/components/Generic"; import type { MetaParsedGeneric } from "@/rdap/hooks/useLookup"; import useLookup from "@/rdap/hooks/useLookup"; @@ -11,12 +12,61 @@ import { Maybe } from "true-myth"; import { Flex, Container, Section, Text, Link, IconButton } from "@radix-ui/themes"; import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "@/lib/url-utils"; +import type { TargetType } from "@/rdap/schemas"; const Index: NextPage = () => { - const { error, setTarget, setTargetType, submit, currentType } = useLookup(); + const router = useRouter(); const [response, setResponse] = useState>(Maybe.nothing()); const [isLoading, setLoading] = useState(false); + // URL update handler for useLookup hook + const handleUrlUpdate = useCallback( + (target: string, manuallySelectedType: TargetType | null) => { + const queryString = serializeQueryToUrl(target, manuallySelectedType); + // Use shallow routing to update URL without page reload + router.push(queryString, undefined, { shallow: true }); + }, + [router] + ); + + const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup( + undefined, + handleUrlUpdate + ); + + // Parse URL parameters on mount and auto-execute query if present + useEffect(() => { + // Only run once on mount, when router is ready + if (!router.isReady) return; + + const searchParams = new URLSearchParams(router.asPath.split("?")[1] || ""); + const queryState = deserializeUrlToQuery(searchParams); + + if (queryState) { + // Set the target and type from URL + setTarget(queryState.query); + if (queryState.type) { + setTargetType(queryState.type); + } + + // Auto-execute the query + setLoading(true); + submit({ + target: queryState.query, + requestJSContact: true, + followReferral: true, + }) + .then(setResponse) + .catch((e) => { + console.error("Error executing query from URL:", e); + setResponse(Maybe.nothing()); + }) + .finally(() => setLoading(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady]); // Only run when router becomes ready + return ( <> @@ -93,6 +143,11 @@ const Index: NextPage = () => { { setTarget(target); setTargetType(targetType); diff --git a/src/rdap/components/LookupInput.tsx b/src/rdap/components/LookupInput.tsx index 5d7db2c..15c2f45 100644 --- a/src/rdap/components/LookupInput.tsx +++ b/src/rdap/components/LookupInput.tsx @@ -8,6 +8,7 @@ import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes"; import type { Maybe } from "true-myth"; import { placeholders } from "@/rdap/constants"; +import ShareButton from "@/components/ShareButton"; /** * Props for the LookupInput component. @@ -33,6 +34,10 @@ type LookupInputProps = { */ onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise; detectedType: Maybe; + /** + * Optional shareable URL to display in the share button. Only shown when provided. + */ + shareableUrl?: string; }; const LookupInput: FunctionComponent = ({ @@ -40,6 +45,7 @@ const LookupInput: FunctionComponent = ({ onSubmit, onChange, detectedType, + shareableUrl, }: LookupInputProps) => { const { register, handleSubmit, getValues } = useForm({ defaultValues: { @@ -216,6 +222,11 @@ const LookupInput: FunctionComponent = ({ )} + {shareableUrl && ( + + + + )} void; +export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void; export type MetaParsedGeneric = { data: ParsedGeneric; url: string; completeTime: Date; }; -const useLookup = (warningHandler?: WarningHandler) => { +const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdateHandler) => { const [error, setError] = useState(null); const [target, setTarget] = useState(""); const [debouncedTarget] = useDebouncedValue(target, 75); @@ -138,7 +139,15 @@ const useLookup = (warningHandler?: WarningHandler) => { if (response.isErr) { setError(response.error.message); console.error(response.error); - } else setError(null); + } else { + setError(null); + + // Update URL after successful query + // currentType is non-null only when user manually selected a type + if (urlUpdateHandler) { + urlUpdateHandler(target, currentType); + } + } return response.isOk ? Maybe.just({ @@ -157,10 +166,12 @@ const useLookup = (warningHandler?: WarningHandler) => { return { error, + target, setTarget, setTargetType, submit, currentType: uriType, + manualType: currentType, getType: getTypeEasy, }; };