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.
This commit is contained in:
2025-10-23 11:54:15 -05:00
parent ada17fc9a9
commit a51d21df83
6 changed files with 340 additions and 4 deletions

View File

@@ -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<ShareButtonProps> = ({
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 (
<Tooltip content={copied ? "Copied!" : "Copy shareable link"}>
<IconButton
size={size}
variant={variant}
color={copied ? "green" : "gray"}
aria-label="Copy shareable link"
onClick={handleShare}
>
{copied ? <CheckIcon /> : <Link2Icon />}
</IconButton>
</Tooltip>
);
};
export default ShareButton;

108
src/lib/url-utils.test.ts Normal file
View File

@@ -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");
});
});
});

85
src/lib/url-utils.ts Normal file
View File

@@ -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}`;
}

View File

@@ -1,6 +1,7 @@
import { type NextPage } from "next"; import { type NextPage } from "next";
import Head from "next/head"; 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 Generic from "@/rdap/components/Generic";
import type { MetaParsedGeneric } from "@/rdap/hooks/useLookup"; import type { MetaParsedGeneric } from "@/rdap/hooks/useLookup";
import useLookup 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 { Flex, Container, Section, Text, Link, IconButton } from "@radix-ui/themes";
import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { GitHubLogoIcon } from "@radix-ui/react-icons";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "@/lib/url-utils";
import type { TargetType } from "@/rdap/schemas";
const Index: NextPage = () => { const Index: NextPage = () => {
const { error, setTarget, setTargetType, submit, currentType } = useLookup(); const router = useRouter();
const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing()); const [response, setResponse] = useState<Maybe<MetaParsedGeneric>>(Maybe.nothing());
const [isLoading, setLoading] = useState<boolean>(false); const [isLoading, setLoading] = useState<boolean>(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 ( return (
<> <>
<Head> <Head>
@@ -93,6 +143,11 @@ const Index: NextPage = () => {
<LookupInput <LookupInput
isLoading={isLoading} isLoading={isLoading}
detectedType={currentType} detectedType={currentType}
shareableUrl={
response.isJust && target && typeof window !== "undefined"
? buildShareableUrl(window.location.origin, target, manualType)
: undefined
}
onChange={({ target, targetType }) => { onChange={({ target, targetType }) => {
setTarget(target); setTarget(target);
setTargetType(targetType); setTargetType(targetType);

View File

@@ -8,6 +8,7 @@ import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react
import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes"; import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes";
import type { Maybe } from "true-myth"; import type { Maybe } from "true-myth";
import { placeholders } from "@/rdap/constants"; import { placeholders } from "@/rdap/constants";
import ShareButton from "@/components/ShareButton";
/** /**
* Props for the LookupInput component. * Props for the LookupInput component.
@@ -33,6 +34,10 @@ type LookupInputProps = {
*/ */
onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise<void>; onChange?: (target: { target: string; targetType: TargetType | null }) => void | Promise<void>;
detectedType: Maybe<TargetType>; detectedType: Maybe<TargetType>;
/**
* Optional shareable URL to display in the share button. Only shown when provided.
*/
shareableUrl?: string;
}; };
const LookupInput: FunctionComponent<LookupInputProps> = ({ const LookupInput: FunctionComponent<LookupInputProps> = ({
@@ -40,6 +45,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
onSubmit, onSubmit,
onChange, onChange,
detectedType, detectedType,
shareableUrl,
}: LookupInputProps) => { }: LookupInputProps) => {
const { register, handleSubmit, getValues } = useForm<SubmitProps>({ const { register, handleSubmit, getValues } = useForm<SubmitProps>({
defaultValues: { defaultValues: {
@@ -216,6 +222,11 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
)} )}
</IconButton> </IconButton>
</TextField.Slot> </TextField.Slot>
{shareableUrl && (
<TextField.Slot side="right">
<ShareButton url={shareableUrl} />
</TextField.Slot>
)}
</TextField.Root> </TextField.Root>
<Select.Root <Select.Root

View File

@@ -14,13 +14,14 @@ import {
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/rdap-query"; import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/rdap-query";
export type WarningHandler = (warning: { message: string }) => void; export type WarningHandler = (warning: { message: string }) => void;
export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void;
export type MetaParsedGeneric = { export type MetaParsedGeneric = {
data: ParsedGeneric; data: ParsedGeneric;
url: string; url: string;
completeTime: Date; completeTime: Date;
}; };
const useLookup = (warningHandler?: WarningHandler) => { const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdateHandler) => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [target, setTarget] = useState<string>(""); const [target, setTarget] = useState<string>("");
const [debouncedTarget] = useDebouncedValue(target, 75); const [debouncedTarget] = useDebouncedValue(target, 75);
@@ -138,7 +139,15 @@ const useLookup = (warningHandler?: WarningHandler) => {
if (response.isErr) { if (response.isErr) {
setError(response.error.message); setError(response.error.message);
console.error(response.error); 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 return response.isOk
? Maybe.just({ ? Maybe.just({
@@ -157,10 +166,12 @@ const useLookup = (warningHandler?: WarningHandler) => {
return { return {
error, error,
target,
setTarget, setTarget,
setTargetType, setTargetType,
submit, submit,
currentType: uriType, currentType: uriType,
manualType: currentType,
getType: getTypeEasy, getType: getTypeEasy,
}; };
}; };