mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 03:16:07 -06:00
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:
66
src/components/ShareButton.tsx
Normal file
66
src/components/ShareButton.tsx
Normal 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
108
src/lib/url-utils.test.ts
Normal 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
85
src/lib/url-utils.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user