From a51d21df837d34dce22d7e1edbd3e010c218aad7 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 23 Oct 2025 11:54:15 -0500 Subject: [PATCH] feat: add shareable URL functionality with copy-to-clipboard button Implement URL parameter serialization for sharing RDAP queries with deep linking support. Add ShareButton component with clipboard integration and visual feedback. Queries are automatically restored from URL parameters on page load, enabling direct navigation to specific RDAP lookups. --- src/components/ShareButton.tsx | 66 +++++++++++++++++ src/lib/url-utils.test.ts | 108 ++++++++++++++++++++++++++++ src/lib/url-utils.ts | 85 ++++++++++++++++++++++ src/pages/index.tsx | 59 ++++++++++++++- src/rdap/components/LookupInput.tsx | 11 +++ src/rdap/hooks/useLookup.tsx | 15 +++- 6 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 src/components/ShareButton.tsx create mode 100644 src/lib/url-utils.test.ts create mode 100644 src/lib/url-utils.ts 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, }; };