mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 03:16:07 -06:00
feat: migrate to Radix UI and implement dark mode with next-themes
Replace @headlessui/react and @heroicons/react with @radix-ui/themes and @radix-ui/react-icons for a more comprehensive component library. Add next-themes for dark mode support with a new ThemeToggle component. Update all components to use Radix UI primitives and theming system, including AbstractCard, DynamicDate, ErrorCard, Property, PropertyList, LookupInput, and all card components (AutnumCard, DomainCard, EntityCard, IPCard, NameserverCard). Update global styles and app configuration to support theme switching.
This commit is contained in:
@@ -22,12 +22,13 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.0.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@swc/helpers": "^0.5.11",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^15.5.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
|
||||
1788
pnpm-lock.yaml
generated
1788
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,8 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import {
|
||||
LinkIcon,
|
||||
CodeBracketIcon,
|
||||
DocumentArrowDownIcon,
|
||||
ClipboardDocumentIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link2Icon, CodeIcon, DownloadIcon, ClipboardCopyIcon } from "@radix-ui/react-icons";
|
||||
import { Card, Flex, Box, IconButton, Code, ScrollArea } from "@radix-ui/themes";
|
||||
|
||||
type AbstractCardProps = {
|
||||
children?: ReactNode;
|
||||
@@ -26,82 +22,125 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
||||
const { value: showRaw, toggle: toggleRaw } = useBoolean(false);
|
||||
|
||||
return (
|
||||
<div className="mb-4 overflow-clip rounded bg-zinc-800 shadow">
|
||||
{header != undefined || data != undefined ? (
|
||||
<div className="flex bg-zinc-700 p-2 pl-3 md:pl-5">
|
||||
<div className="flex grow gap-2">{header}</div>
|
||||
{url != undefined ? (
|
||||
<div className="pr-2">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<LinkIcon className="mt-1 h-5 w-5 cursor-pointer" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{data != undefined ? (
|
||||
<>
|
||||
<div className="pr-2">
|
||||
<ClipboardDocumentIcon
|
||||
onClick={() => {
|
||||
// stringify the JSON object, then begin the async clipboard write
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(data, null, 4))
|
||||
.then(
|
||||
() => {
|
||||
// Successfully copied to clipboard
|
||||
},
|
||||
(err) => {
|
||||
if (err instanceof Error)
|
||||
console.error(
|
||||
`Failed to copy to clipboard (${err.toString()}).`
|
||||
);
|
||||
else
|
||||
console.error(
|
||||
"Failed to copy to clipboard."
|
||||
);
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="pr-2">
|
||||
<DocumentArrowDownIcon
|
||||
onClick={() => {
|
||||
const file = new Blob([JSON.stringify(data, null, 4)], {
|
||||
type: "application/json",
|
||||
});
|
||||
<Box mb="4">
|
||||
<Card size="2">
|
||||
{(header != undefined || data != undefined) && (
|
||||
<Flex
|
||||
justify="between"
|
||||
align="center"
|
||||
p="3"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-a5)",
|
||||
}}
|
||||
>
|
||||
<Flex gap="2" style={{ flex: 1 }}>
|
||||
{header}
|
||||
</Flex>
|
||||
<Flex gap="2" align="center">
|
||||
{url != undefined && (
|
||||
<IconButton variant="ghost" size="2" asChild>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open RDAP URL"
|
||||
>
|
||||
<Link2Icon width="18" height="18" />
|
||||
</a>
|
||||
</IconButton>
|
||||
)}
|
||||
{data != undefined && (
|
||||
<>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="2"
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(data, null, 4))
|
||||
.then(
|
||||
() => {
|
||||
// Successfully copied to clipboard
|
||||
},
|
||||
(err) => {
|
||||
if (err instanceof Error)
|
||||
console.error(
|
||||
`Failed to copy to clipboard (${err.toString()}).`
|
||||
);
|
||||
else
|
||||
console.error(
|
||||
"Failed to copy to clipboard."
|
||||
);
|
||||
}
|
||||
);
|
||||
}}
|
||||
aria-label="Copy JSON to clipboard"
|
||||
>
|
||||
<ClipboardCopyIcon width="18" height="18" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="2"
|
||||
onClick={() => {
|
||||
const file = new Blob([JSON.stringify(data, null, 4)], {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = URL.createObjectURL(file);
|
||||
anchor.download = "response.json";
|
||||
anchor.click();
|
||||
}}
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="pr-1">
|
||||
<CodeBracketIcon
|
||||
onClick={toggleRaw}
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-w-full overflow-x-auto p-2 px-4">
|
||||
{showRaw ? (
|
||||
<pre className="scrollbar-thin m-2 max-h-[40rem] max-w-full overflow-y-auto rounded whitespace-pre-wrap">
|
||||
{JSON.stringify(data, null, 4)}
|
||||
</pre>
|
||||
) : (
|
||||
children
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = URL.createObjectURL(file);
|
||||
anchor.download = "response.json";
|
||||
anchor.click();
|
||||
}}
|
||||
aria-label="Download JSON"
|
||||
>
|
||||
<DownloadIcon width="18" height="18" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="2"
|
||||
onClick={toggleRaw}
|
||||
aria-label={
|
||||
showRaw ? "Show formatted view" : "Show raw JSON"
|
||||
}
|
||||
>
|
||||
<CodeIcon width="18" height="18" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
{footer != null ? (
|
||||
<div className="flex gap-2 bg-zinc-700 p-2 pl-5">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Box p="4">
|
||||
{showRaw ? (
|
||||
<ScrollArea type="auto" scrollbars="both" style={{ maxHeight: "40rem" }}>
|
||||
<Code
|
||||
variant="ghost"
|
||||
size="2"
|
||||
style={{
|
||||
display: "block",
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(data, null, 4)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Box>
|
||||
{footer != null && (
|
||||
<Flex
|
||||
gap="2"
|
||||
p="3"
|
||||
style={{
|
||||
borderTop: "1px solid var(--gray-a5)",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FunctionComponent } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import { format } from "date-fns";
|
||||
import TimeAgo from "react-timeago";
|
||||
import { Button, Text } from "@radix-ui/themes";
|
||||
|
||||
type DynamicDateProps = {
|
||||
value: Date | number;
|
||||
@@ -18,15 +19,20 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({ value, absoluteForma
|
||||
|
||||
const date = new Date(value);
|
||||
return (
|
||||
<button onClick={toggleFormat}>
|
||||
<span className="dashed" title={date.toISOString()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="1"
|
||||
onClick={toggleFormat}
|
||||
style={{ padding: 0, height: "auto" }}
|
||||
>
|
||||
<Text className="dashed" title={date.toISOString()} size="2">
|
||||
{showAbsolute ? (
|
||||
format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx")
|
||||
) : (
|
||||
<TimeAgo date={date} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import { XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CrossCircledIcon } from "@radix-ui/react-icons";
|
||||
import { Callout, Box, Flex } from "@radix-ui/themes";
|
||||
|
||||
export type ErrorCardProps = {
|
||||
title: ReactNode;
|
||||
@@ -16,35 +16,46 @@ const ErrorCard: FunctionComponent<ErrorCardProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"rounded-md border border-red-700/30 bg-zinc-800 px-3 pt-3 pb-1"
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="shrink-0">
|
||||
<XCircleIcon className="h-5 w-5 text-red-300" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-full text-sm text-red-300">
|
||||
<h3 className="font-medium text-red-200">{title}</h3>
|
||||
<Box className={className}>
|
||||
<Callout.Root color="red" role="alert">
|
||||
<Callout.Icon>
|
||||
<CrossCircledIcon />
|
||||
</Callout.Icon>
|
||||
<Flex direction="column" gap="2">
|
||||
<Callout.Text weight="medium" size="3">
|
||||
{title}
|
||||
</Callout.Text>
|
||||
{description != undefined ? (
|
||||
<div className="mt-2 max-h-24 w-full overflow-y-auto whitespace-pre-wrap">
|
||||
{description}
|
||||
</div>
|
||||
<Box
|
||||
style={{
|
||||
maxHeight: "6rem",
|
||||
overflowY: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
<Callout.Text size="2">{description}</Callout.Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<div className="mt-2">
|
||||
{issues != undefined ? (
|
||||
<ul role="list" className="flex list-disc flex-col gap-1 pl-5">
|
||||
{issues != undefined && issues.length > 0 ? (
|
||||
<Box asChild>
|
||||
<ul
|
||||
role="list"
|
||||
style={{
|
||||
listStyleType: "disc",
|
||||
paddingLeft: "1.25rem",
|
||||
}}
|
||||
>
|
||||
{issues.map((issueText, index) => (
|
||||
<li key={index}>{issueText}</li>
|
||||
<li key={index}>
|
||||
<Callout.Text size="2">{issueText}</Callout.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Callout.Root>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DataList } from "@radix-ui/themes";
|
||||
|
||||
type PropertyProps = {
|
||||
title: string | ReactNode;
|
||||
@@ -9,6 +9,11 @@ type PropertyProps = {
|
||||
valueClass?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A simple wrapper around Radix DataList for displaying key-value pairs.
|
||||
* This component uses DataList.Item, DataList.Label, and DataList.Value
|
||||
* to provide semantic HTML and consistent styling.
|
||||
*/
|
||||
const Property: FunctionComponent<PropertyProps> = ({
|
||||
title,
|
||||
children,
|
||||
@@ -16,10 +21,10 @@ const Property: FunctionComponent<PropertyProps> = ({
|
||||
valueClass,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<dt className={cn("font-medium", titleClass)}>{title}:</dt>
|
||||
<dd className={cn("mt-2 mb-2 ml-6", valueClass)}>{children}</dd>
|
||||
</>
|
||||
<DataList.Item>
|
||||
<DataList.Label className={titleClass}>{title}</DataList.Label>
|
||||
<DataList.Value className={valueClass}>{children}</DataList.Value>
|
||||
</DataList.Item>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import React from "react";
|
||||
import Property from "@/components/common/Property";
|
||||
import { Text, Box, DataList } from "@radix-ui/themes";
|
||||
|
||||
const PropertyListItem: FunctionComponent<{
|
||||
title: string;
|
||||
children: string;
|
||||
}> = ({ title, children }) => {
|
||||
return (
|
||||
<li>
|
||||
<span className="dashed" title={title}>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
<Box asChild>
|
||||
<li>
|
||||
<Text className="dashed" title={title} size="2">
|
||||
{children}
|
||||
</Text>
|
||||
</li>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,15 +22,30 @@ type PropertyListProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* PropertyList displays a labeled list of items (not key-value pairs).
|
||||
* Uses DataList.Item for the label, and renders children as a bulleted list.
|
||||
*/
|
||||
const PropertyList: FunctionComponent<PropertyListProps> & {
|
||||
Item: typeof PropertyListItem;
|
||||
} = ({ title, children }) => {
|
||||
return (
|
||||
<Property title={title}>
|
||||
<ul key={2} className="list-disc">
|
||||
{children}
|
||||
</ul>
|
||||
</Property>
|
||||
<DataList.Item>
|
||||
<DataList.Label>{title}</DataList.Label>
|
||||
<DataList.Value>
|
||||
<Box asChild>
|
||||
<ul
|
||||
style={{
|
||||
listStyleType: "disc",
|
||||
paddingLeft: "1.25rem",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</Box>
|
||||
</DataList.Value>
|
||||
</DataList.Item>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
40
src/components/common/ThemeToggle.tsx
Normal file
40
src/components/common/ThemeToggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { IconButton } from "@radix-ui/themes";
|
||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Avoid hydration mismatch by only rendering after mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="3"
|
||||
variant="ghost"
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
title={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<MoonIcon width="18" height="18" />
|
||||
) : (
|
||||
<SunIcon width="18" height="18" />
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,11 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { onPromise, preventDefault } from "@/helpers";
|
||||
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/types";
|
||||
import { TargetTypeEnum } from "@/schema";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
LockClosedIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 "@/constants";
|
||||
|
||||
@@ -54,7 +41,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
onChange,
|
||||
detectedType,
|
||||
}: LookupInputProps) => {
|
||||
const { register, handleSubmit, getValues } = useForm<SubmitProps>({
|
||||
const { register, handleSubmit, getValues, control } = useForm<SubmitProps>({
|
||||
defaultValues: {
|
||||
target: "",
|
||||
// Not used at this time.
|
||||
@@ -115,200 +102,143 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
className={cn({
|
||||
"absolute inset-y-0 left-0 flex items-center pl-3": true,
|
||||
"pointer-events-none": isLoading,
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ArrowPathIcon
|
||||
className="h-5 w-5 animate-spin text-zinc-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const searchInput = (
|
||||
<input
|
||||
className={cn(
|
||||
"block w-full rounded-l-md border border-transparent lg:py-4.5",
|
||||
"bg-zinc-700 py-2 pr-1.5 pl-10 text-sm placeholder-zinc-400 placeholder:translate-y-2 focus:text-zinc-200",
|
||||
"focus:outline-hidden sm:text-sm md:py-3 md:text-base lg:text-lg"
|
||||
)}
|
||||
disabled={isLoading}
|
||||
placeholder={placeholders[selected]}
|
||||
type="search"
|
||||
{...register("target", {
|
||||
required: true,
|
||||
onChange: () => {
|
||||
if (onChange != undefined)
|
||||
void onChange({
|
||||
target: getValues("target"),
|
||||
// dropdown target will be pulled from state anyways, so no need to provide it here
|
||||
targetType: retrieveTargetType(null),
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdown = (
|
||||
<Listbox
|
||||
value={selected}
|
||||
onChange={(value) => {
|
||||
setSelected(value);
|
||||
|
||||
if (onChange != undefined)
|
||||
void onChange({
|
||||
target: getValues("target"),
|
||||
// we provide the value as the state will not have updated yet for this context
|
||||
targetType: retrieveTargetType(value),
|
||||
});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className={cn(
|
||||
"relative h-full w-full cursor-default rounded-r-lg bg-zinc-700 py-2 pr-10 pl-1 text-right whitespace-nowrap",
|
||||
"text-xs focus:outline-hidden focus-visible:border-indigo-500 sm:text-sm md:text-base lg:text-lg",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300"
|
||||
)}
|
||||
>
|
||||
{/* Fetch special text for 'auto' mode, otherwise just use the options. */}
|
||||
<span className="block">
|
||||
{selected == "auto" ? (
|
||||
// If the detected type was provided, then notate which in parentheses. Compact object naming might be better in the future.
|
||||
detectedType.isJust ? (
|
||||
<>
|
||||
Auto (
|
||||
<span className="animate-pulse">
|
||||
{targetShortNames[detectedType.value]}
|
||||
</span>
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
objectNames["auto"]
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<LockClosedIcon
|
||||
className="mr-2.5 mb-1 inline h-4 w-4 animate-pulse text-zinc-500"
|
||||
aria-hidden
|
||||
/>
|
||||
{objectNames[selected]}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-zinc-200" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
className={cn(
|
||||
"scrollbar-thin absolute right-0 mt-1 max-h-60 min-w-full overflow-auto rounded-md bg-zinc-700 py-1",
|
||||
"text-zinc-200 shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
|
||||
)}
|
||||
>
|
||||
{Object.entries(objectNames).map(([key, value]) => (
|
||||
<ListboxOption
|
||||
key={key}
|
||||
className={({ focus }) =>
|
||||
cn(
|
||||
"relative cursor-default py-2 pr-4 pl-10 select-none",
|
||||
focus ? "bg-zinc-800 text-zinc-300" : null
|
||||
)
|
||||
}
|
||||
value={key}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"block text-right text-xs whitespace-nowrap md:text-sm lg:text-base",
|
||||
selected ? "font-medium" : null
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-500">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// TODO: Show Help Explanation
|
||||
}}
|
||||
className="absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-bold opacity-20 hover:animate-pulse"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="pb-3"
|
||||
className="pb-2.5"
|
||||
onSubmit={onSubmit != undefined ? onPromise(handleSubmit(onSubmit)) : preventDefault}
|
||||
>
|
||||
<div className="col">
|
||||
<Flex direction="column" gap="3">
|
||||
<label htmlFor="search" className="sr-only">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative flex">
|
||||
{searchIcon}
|
||||
{searchInput}
|
||||
{dropdown}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="flex flex-wrap pt-3 pb-1 text-sm">
|
||||
<div className="whitespace-nowrap">
|
||||
<input
|
||||
className="mr-1 ml-2 whitespace-nowrap text-zinc-800 accent-blue-700"
|
||||
type="checkbox"
|
||||
{...register("requestJSContact")}
|
||||
/>
|
||||
<label className="text-zinc-300" htmlFor="requestJSContact">
|
||||
<Flex gap="0" style={{ position: "relative" }}>
|
||||
<TextField.Root
|
||||
size="3"
|
||||
placeholder={placeholders[selected]}
|
||||
disabled={isLoading}
|
||||
{...register("target", {
|
||||
required: true,
|
||||
onChange: () => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<TextField.Slot side="left">
|
||||
<IconButton
|
||||
size="1"
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
tabIndex={-1}
|
||||
style={{ cursor: isLoading ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ReloadIcon className="animate-spin" width="16" height="16" />
|
||||
) : (
|
||||
<MagnifyingGlassIcon width="16" height="16" />
|
||||
)}
|
||||
</IconButton>
|
||||
</TextField.Slot>
|
||||
</TextField.Root>
|
||||
|
||||
<Select.Root
|
||||
value={selected}
|
||||
onValueChange={(value) => {
|
||||
setSelected(value as SimplifiedTargetType | "auto");
|
||||
|
||||
if (onChange != undefined)
|
||||
void onChange({
|
||||
target: getValues("target"),
|
||||
targetType: retrieveTargetType(value),
|
||||
});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
size="3"
|
||||
>
|
||||
<Select.Trigger
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
|
||||
minWidth: "150px",
|
||||
}}
|
||||
>
|
||||
{selected == "auto" ? (
|
||||
detectedType.isJust ? (
|
||||
<Text>Auto ({targetShortNames[detectedType.value]})</Text>
|
||||
) : (
|
||||
objectNames["auto"]
|
||||
)
|
||||
) : (
|
||||
<Flex align="center" gap="2">
|
||||
<LockClosedIcon width="14" height="14" />
|
||||
{objectNames[selected]}
|
||||
</Flex>
|
||||
)}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content position="popper">
|
||||
{Object.entries(objectNames).map(([key, value]) => (
|
||||
<Select.Item key={key} value={key}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="between"
|
||||
gap="2"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{value}
|
||||
</Flex>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex pl="3" gapX="5" gapY="2" wrap="wrap">
|
||||
<Flex asChild align="center" gap="2">
|
||||
<Text as="label" size="2">
|
||||
<Controller
|
||||
name="requestJSContact"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
Request JSContact
|
||||
</label>
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
<input
|
||||
className="mr-1 ml-2 bg-zinc-500 text-inherit accent-blue-700"
|
||||
type="checkbox"
|
||||
{...register("followReferral")}
|
||||
/>
|
||||
<label className="text-zinc-300" htmlFor="followReferral">
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex asChild align="center" gap="2">
|
||||
<Text as="label" size="2">
|
||||
<Controller
|
||||
name="followReferral"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
Follow referral to registrar's RDAP record
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import Events from "@/components/lookup/Events";
|
||||
import Property from "@/components/common/Property";
|
||||
import PropertyList from "@/components/common/PropertyList";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||
|
||||
export type AutnumCardProps = {
|
||||
data: AutonomousNumber;
|
||||
@@ -22,14 +23,13 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">AUTONOMOUS SYSTEM</span>
|
||||
<span className="font-mono tracking-wide">{asnRange}</span>
|
||||
<span className="whitespace-nowrap">({data.handle})</span>
|
||||
</>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text size="5">{asnRange}</Text>
|
||||
<Badge color="gray">AUTONOMOUS SYSTEM</Badge>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
|
||||
<Property title="Name">{data.name}</Property>
|
||||
<Property title="Handle">{data.handle}</Property>
|
||||
<Property title="ASN Range">
|
||||
@@ -49,7 +49,7 @@ const AutnumCard: FunctionComponent<AutnumCardProps> = ({ data, url }: AutnumCar
|
||||
</PropertyList.Item>
|
||||
))}
|
||||
</PropertyList>
|
||||
</dl>
|
||||
</DataList.Root>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import Events from "@/components/lookup/Events";
|
||||
import Property from "@/components/common/Property";
|
||||
import PropertyList from "@/components/common/PropertyList";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||
|
||||
export type DomainProps = {
|
||||
data: Domain;
|
||||
@@ -18,16 +19,13 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">DOMAIN</span>
|
||||
<span className="font-mono tracking-wide">
|
||||
{data.ldhName ?? data.unicodeName}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">({data.handle})</span>
|
||||
</>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text size="5">{data.ldhName ?? data.unicodeName}</Text>
|
||||
<Badge color="gray">DOMAIN</Badge>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
|
||||
{data.unicodeName != undefined ? (
|
||||
<Property title="Unicode Name">{data.unicodeName}</Property>
|
||||
) : null}
|
||||
@@ -45,7 +43,7 @@ const DomainCard: FunctionComponent<DomainProps> = ({ data, url }: DomainProps)
|
||||
</PropertyList.Item>
|
||||
))}
|
||||
</PropertyList>
|
||||
</dl>
|
||||
</DataList.Root>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Entity } from "@/types";
|
||||
import Property from "@/components/common/Property";
|
||||
import PropertyList from "@/components/common/PropertyList";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
||||
|
||||
export type EntityCardProps = {
|
||||
data: Entity;
|
||||
@@ -16,15 +17,13 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">ENTITY</span>
|
||||
<span className="font-mono tracking-wide">
|
||||
{data.handle || data.roles.join(", ")}
|
||||
</span>
|
||||
</>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text size="5">{data.handle || data.roles.join(", ")}</Text>
|
||||
<Badge color="gray">ENTITY</Badge>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
|
||||
{data.handle && <Property title="Handle">{data.handle}</Property>}
|
||||
<PropertyList title="Roles">
|
||||
{data.roles.map((role, index) => (
|
||||
@@ -42,7 +41,7 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
|
||||
))}
|
||||
</PropertyList>
|
||||
)}
|
||||
</dl>
|
||||
</DataList.Root>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import type { Event } from "@/types";
|
||||
import { Fragment } from "react";
|
||||
import DynamicDate from "@/components/common/DynamicDate";
|
||||
import { Table, Text } from "@radix-ui/themes";
|
||||
|
||||
export type EventsProps = {
|
||||
data: Event[];
|
||||
};
|
||||
|
||||
const Events: FunctionComponent<EventsProps> = ({ data }) => {
|
||||
return (
|
||||
<dl>
|
||||
{data.map(({ eventAction, eventDate, eventActor }, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<dt className="font-weight-bolder">{eventAction}:</dt>
|
||||
<dd>
|
||||
<Table.Root size="1" variant="surface">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeaderCell>Event</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Date</Table.ColumnHeaderCell>
|
||||
<Table.ColumnHeaderCell>Actor</Table.ColumnHeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map(({ eventAction, eventDate, eventActor }, index) => (
|
||||
<Table.Row key={index}>
|
||||
<Table.Cell>
|
||||
<Text size="2" weight="medium">
|
||||
{eventAction}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DynamicDate value={new Date(eventDate)} />
|
||||
{eventActor != null ? ` (by ${eventActor})` : null}
|
||||
</dd>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text size="2" color="gray">
|
||||
{eventActor ?? "—"}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,12 +35,6 @@ const Generic: FunctionComponent<ObjectProps> = ({ data, url }: ObjectProps) =>
|
||||
</AbstractCard>
|
||||
);
|
||||
}
|
||||
|
||||
// const title: string = (data.unicodeName ?? data.ldhName ?? data.handle)?.toUpperCase() ?? "Response";
|
||||
// return <div className="card">
|
||||
// <div className="card-header">{title}</div>
|
||||
// {objectFragment}
|
||||
// </div>
|
||||
};
|
||||
|
||||
export default Generic;
|
||||
|
||||
@@ -5,6 +5,7 @@ import Events from "@/components/lookup/Events";
|
||||
import Property from "@/components/common/Property";
|
||||
import PropertyList from "@/components/common/PropertyList";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
import { Flex, Text, DataList, Badge } from "@radix-ui/themes";
|
||||
|
||||
export type IPCardProps = {
|
||||
data: IpNetwork;
|
||||
@@ -17,17 +18,15 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">IP NETWORK</span>
|
||||
<span className="font-mono tracking-wide">
|
||||
{data.startAddress}
|
||||
{data.startAddress !== data.endAddress && ` - ${data.endAddress}`}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">({data.handle})</span>
|
||||
</>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text size="5">
|
||||
{data.startAddress} - {data.endAddress}
|
||||
</Text>
|
||||
<Badge color="gray">IP NETWORK</Badge>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
|
||||
<Property title="Name">{data.name}</Property>
|
||||
<Property title="Handle">{data.handle}</Property>
|
||||
<Property title="IP Version">{data.ipVersion.toUpperCase()}</Property>
|
||||
@@ -48,7 +47,7 @@ const IPCard: FunctionComponent<IPCardProps> = ({ data, url }: IPCardProps) => {
|
||||
</PropertyList.Item>
|
||||
))}
|
||||
</PropertyList>
|
||||
</dl>
|
||||
</DataList.Root>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import type { Nameserver } from "@/types";
|
||||
import Property from "@/components/common/Property";
|
||||
import AbstractCard from "@/components/common/AbstractCard";
|
||||
import { Flex, DataList, Badge, Text } from "@radix-ui/themes";
|
||||
|
||||
export type NameserverCardProps = {
|
||||
data: Nameserver;
|
||||
@@ -18,15 +19,15 @@ const NameserverCard: FunctionComponent<NameserverCardProps> = ({
|
||||
data={data}
|
||||
url={url}
|
||||
header={
|
||||
<>
|
||||
<span className="font-mono tracking-tighter">NAMESERVER</span>
|
||||
<span className="font-mono tracking-wide">{data.ldhName}</span>
|
||||
</>
|
||||
<Flex gap="2" align="center" wrap="wrap">
|
||||
<Text size="5">{data.ldhName}</Text>
|
||||
<Badge color="gray">NAMESERVER</Badge>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<dl>
|
||||
<DataList.Root orientation={{ initial: "vertical", sm: "horizontal" }} size="2">
|
||||
<Property title="LDH Name">{data.ldhName}</Property>
|
||||
</dl>
|
||||
</DataList.Root>
|
||||
</AbstractCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { type AppType } from "next/dist/shared/lib/utils";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Theme } from "@radix-ui/themes";
|
||||
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/ibm-plex-mono/400.css";
|
||||
import "@radix-ui/themes/styles.css";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
const MyApp: AppType = ({ Component, pageProps }) => {
|
||||
return <Component {...pageProps} />;
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="system">
|
||||
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
|
||||
<Component {...pageProps} />
|
||||
</Theme>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyApp;
|
||||
|
||||
@@ -6,8 +6,10 @@ import type { MetaParsedGeneric } from "@/hooks/useLookup";
|
||||
import useLookup from "@/hooks/useLookup";
|
||||
import LookupInput from "@/components/form/LookupInput";
|
||||
import ErrorCard from "@/components/common/ErrorCard";
|
||||
import { ThemeToggle } from "@/components/common/ThemeToggle";
|
||||
import { Maybe } from "true-myth";
|
||||
import type { TargetType } from "@/types";
|
||||
import { Flex, Container, Section, Text, Link } from "@radix-ui/themes";
|
||||
|
||||
const Index: NextPage = () => {
|
||||
const { error, setTarget, setTargetType, submit, getType } = useLookup();
|
||||
@@ -37,16 +39,30 @@ const Index: NextPage = () => {
|
||||
content="xevion, rdap, whois, rdap, domain name, dns, ip address"
|
||||
/>
|
||||
</Head>
|
||||
<nav className="bg-zinc-850 px-5 py-4 shadow-xs">
|
||||
<span className="text-xl font-medium text-white" style={{ fontSize: "larger" }}>
|
||||
<a href="https://github.com/Xevion/rdap">rdap</a>
|
||||
<a href={"https://xevion.dev"} className="text-zinc-400 hover:animate-pulse">
|
||||
.xevion.dev
|
||||
</a>
|
||||
</span>
|
||||
</nav>
|
||||
<div className="mx-auto max-w-screen-sm px-5 lg:max-w-screen-md xl:max-w-screen-lg">
|
||||
<div className="dark container mx-auto w-full py-6 md:py-12">
|
||||
<Flex
|
||||
asChild
|
||||
justify="between"
|
||||
align="center"
|
||||
px="5"
|
||||
py="4"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--gray-a5)",
|
||||
}}
|
||||
>
|
||||
<nav>
|
||||
<Text size="5" weight="medium">
|
||||
<Link href="https://github.com/Xevion/rdap" color="gray" highContrast>
|
||||
rdap
|
||||
</Link>
|
||||
<Link href="https://xevion.dev" color="gray">
|
||||
.xevion.dev
|
||||
</Link>
|
||||
</Text>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</Flex>
|
||||
<Container size="3" px="5">
|
||||
<Section size="2">
|
||||
<LookupInput
|
||||
isLoading={isLoading}
|
||||
detectedType={detectedType}
|
||||
@@ -83,8 +99,8 @@ const Index: NextPage = () => {
|
||||
{response.isJust ? (
|
||||
<Generic url={response.value.url} data={response.value.data} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,31 +7,27 @@
|
||||
--font-mono:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--color-zinc-850: #1d1d20;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.5em 0 1em 2em;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.dashed {
|
||||
border-bottom: 1px dashed silver;
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: dark;
|
||||
@apply bg-zinc-900 font-sans text-white;
|
||||
}
|
||||
|
||||
dd,
|
||||
dl {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
border-bottom: 1px dashed var(--gray-a6);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Keep animate-spin for loading states */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user