mirror of
https://github.com/ershisan99/DevToysWeb.git
synced 2025-12-16 20:49:23 +00:00
renewal
recreate project by using https://github.com/shadcn/next-template App: - support dark mode - add toggle theme button - add clear search button - add search button - add current page indicator - add tool group pages - add settings tool - add 1 tab format option to Json format tool - add paste button to some tools - add file button to some tools - add copy button to some tools - add clear button to some tools - change favicon - change search hit rate - change each page title - change icons from Material Icons to Lucide - change sidebar scroll area - change editor from Ace to Monaco - change parsable separators of number base converter - change default value of format option of number base converter - change default values of some tool forms - change some styles - remove disabled tools - remove real-time search - fix uri encoding tool Dev: - MUI + Emotion -> Radix UI + Tailwind CSS - Next.js 12 Pages -> Next.js 13 App Router - React 17 -> React 18 - many other packages upgraded - use useState instead of recoil - use Next.js typedRoutes instead of pathpida - clean npm scripts - format import statements by Prettier - no component separations between container and presenter - effective component memoizations - add vscode settings - many refactors
This commit is contained in:
30
components/buttons/base.tsx
Normal file
30
components/buttons/base.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Button, ButtonProps } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export type BaseButtonProps = ButtonProps & {
|
||||
icon: React.ReactNode;
|
||||
iconOnly?: true;
|
||||
labelText: string;
|
||||
};
|
||||
|
||||
export function BaseButton({ icon, iconOnly, labelText, ...props }: BaseButtonProps) {
|
||||
const button = (
|
||||
<Button className="w-fit border" {...props}>
|
||||
{icon}
|
||||
{!iconOnly && <span className="ml-1">{labelText}</span>}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return iconOnly ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{labelText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
13
components/buttons/clear.tsx
Normal file
13
components/buttons/clear.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
import { BaseButton, BaseButtonProps } from "./base";
|
||||
|
||||
export type ClearButtonProps = Omit<BaseButtonProps, "icon" | "labelText">;
|
||||
|
||||
export function ClearButton({ iconOnly, ...props }: ClearButtonProps) {
|
||||
const icon = useMemo(() => <icons.X size={16} />, []);
|
||||
|
||||
return <BaseButton {...props} {...{ icon, iconOnly }} labelText="Clear" />;
|
||||
}
|
||||
24
components/buttons/copy.tsx
Normal file
24
components/buttons/copy.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
import { BaseButton, BaseButtonProps } from "./base";
|
||||
|
||||
export type CopyButtonProps = Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function CopyButton({ text, iconOnly, ...props }: CopyButtonProps) {
|
||||
const onClick: BaseButtonProps["onClick"] = useCallback(() => {
|
||||
navigator.clipboard.writeText(text).catch(e => {
|
||||
if (e instanceof Error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
}, [text]);
|
||||
|
||||
const icon = useMemo(() => <icons.Copy size={16} />, []);
|
||||
|
||||
return <BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Copy" />;
|
||||
}
|
||||
66
components/buttons/file.tsx
Normal file
66
components/buttons/file.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
import { BaseButton, BaseButtonProps } from "./base";
|
||||
|
||||
type InputProps = React.ComponentProps<"input">;
|
||||
|
||||
export type FileButtonProps = Pick<InputProps, "accept"> &
|
||||
Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||
maxFileSizeMb?: number;
|
||||
onFileRead: (text: string) => void;
|
||||
};
|
||||
|
||||
export function FileButton({
|
||||
accept,
|
||||
iconOnly,
|
||||
maxFileSizeMb = 20,
|
||||
onFileRead,
|
||||
...props
|
||||
}: FileButtonProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onClick = () => ref.current?.click();
|
||||
|
||||
const onChange: NonNullable<InputProps["onChange"]> = useCallback(
|
||||
({ currentTarget }) => {
|
||||
const file = Array.from(currentTarget.files ?? []).at(0);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: reject if the file is unsupported
|
||||
|
||||
if (file.size > maxFileSizeMb * 2 ** 20) {
|
||||
// eslint-disable-next-line no-alert
|
||||
return alert(`The file is too big. Up to ${maxFileSizeMb}MiB.`);
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = ({ target }) => {
|
||||
if (typeof target?.result === "string") {
|
||||
onFileRead(target?.result);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
|
||||
// clear selected file to accept the same file again
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
currentTarget.value = "";
|
||||
},
|
||||
[maxFileSizeMb, onFileRead]
|
||||
);
|
||||
|
||||
const icon = useMemo(() => <icons.File size={16} />, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Load a file" />
|
||||
<input hidden type="file" {...{ ref, accept, onChange }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
components/buttons/paste.tsx
Normal file
27
components/buttons/paste.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
import { BaseButton, BaseButtonProps } from "./base";
|
||||
|
||||
export type PasteButtonProps = Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||
onClipboardRead: (text: string) => void;
|
||||
};
|
||||
|
||||
export function PasteButton({ iconOnly, onClipboardRead, ...props }: PasteButtonProps) {
|
||||
const onClick: BaseButtonProps["onClick"] = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then(onClipboardRead)
|
||||
.catch(e => {
|
||||
if (e instanceof Error) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(e.message);
|
||||
}
|
||||
});
|
||||
}, [onClipboardRead]);
|
||||
|
||||
const icon = useMemo(() => <icons.Clipboard size={16} />, []);
|
||||
|
||||
return <BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Paste" />;
|
||||
}
|
||||
23
components/configuration.tsx
Normal file
23
components/configuration.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
type Props = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
control: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Configuration({ icon, title, description, control }: Props) {
|
||||
return (
|
||||
<div className="flex h-16 items-center gap-6 rounded border bg-configuration px-4">
|
||||
{icon}
|
||||
{description ? (
|
||||
<div className="flex flex-col">
|
||||
<span>{title}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{title}</span>
|
||||
)}
|
||||
<div className="flex flex-1 justify-end">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/configurations.tsx
Normal file
20
components/configurations.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { memo } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
type Props = {
|
||||
list: React.ReactNode[];
|
||||
};
|
||||
|
||||
function RawConfigurations({ list }: Props) {
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{list.map((config, i) => (
|
||||
// re-render does not change the order
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={i}>{config}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const Configurations = memo(RawConfigurations, equal);
|
||||
20
components/control-menu.tsx
Normal file
20
components/control-menu.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { memo } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
type Props = {
|
||||
list: React.ReactNode[];
|
||||
};
|
||||
|
||||
function RawControlMenu({ list }: Props) {
|
||||
return (
|
||||
<menu className="flex gap-2">
|
||||
{list.map((control, i) => (
|
||||
// re-render does not change the order
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<li key={i}>{control}</li>
|
||||
))}
|
||||
</menu>
|
||||
);
|
||||
}
|
||||
|
||||
export const ControlMenu = memo(RawControlMenu, equal);
|
||||
71
components/icons.tsx
Normal file
71
components/icons.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowRightLeft,
|
||||
Binary,
|
||||
Braces,
|
||||
CaseSensitive,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
Code2,
|
||||
Copy,
|
||||
Equal,
|
||||
FileIcon,
|
||||
Fingerprint,
|
||||
Hash,
|
||||
Home,
|
||||
Key,
|
||||
Link2,
|
||||
LucideProps,
|
||||
Minus,
|
||||
Moon,
|
||||
PackagePlus,
|
||||
Paintbrush2,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Space,
|
||||
SunMedium,
|
||||
X,
|
||||
type Icon as LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type Icon = LucideIcon;
|
||||
|
||||
export const icons = {
|
||||
AlignLeft,
|
||||
ArrowRightLeft,
|
||||
Binary,
|
||||
Braces,
|
||||
CaseSensitive,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
Code: Code2,
|
||||
Copy,
|
||||
Equal,
|
||||
File: FileIcon,
|
||||
Fingerprint,
|
||||
Hash,
|
||||
Home,
|
||||
Key,
|
||||
Link: Link2,
|
||||
PackagePlus,
|
||||
Paintbrush: Paintbrush2,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Space,
|
||||
Sun: SunMedium,
|
||||
Minus,
|
||||
Moon,
|
||||
X,
|
||||
GitHub: (props: LucideProps) => (
|
||||
<svg viewBox="0 0 438.549 438.549" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
7
components/indicator.tsx
Normal file
7
components/indicator.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { memo } from "react";
|
||||
|
||||
function RawIndicator() {
|
||||
return <span className="inline-block h-[18px] w-[3px] rounded bg-indicator" />;
|
||||
}
|
||||
|
||||
export const Indicator = memo(RawIndicator);
|
||||
19
components/labeled-switch.tsx
Normal file
19
components/labeled-switch.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch, SwitchProps } from "@/components/ui/switch";
|
||||
|
||||
type Props = Omit<SwitchProps, "id"> & {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LabeledSwitch({ id, label, ...props }: Props) {
|
||||
return (
|
||||
// reverse to apply peer style
|
||||
<div className="flex flex-row-reverse items-center">
|
||||
<Switch className="peer" {...{ id }} {...props} />
|
||||
<Label className="cursor-pointer pr-3" htmlFor={id}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
components/page-root-section.tsx
Normal file
14
components/page-root-section.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
type Props = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function PageRootSection({ className, children, title }: Props) {
|
||||
return (
|
||||
<section {...{ className }}>
|
||||
<h1 className="mb-6 text-2xl">{title}</h1>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
25
components/page-section.tsx
Normal file
25
components/page-section.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
control?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageSection({ className, children, title, control }: Props) {
|
||||
return (
|
||||
<section className={cn("mt-3 flex flex-col", className)}>
|
||||
{title &&
|
||||
(control ? (
|
||||
<div className="mb-1.5 flex w-full items-end">
|
||||
<h2 className="text-base">{title}</h2>
|
||||
<div className="ml-auto">{control}</div>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="mb-1.5 text-base">{title}</h2>
|
||||
))}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
components/sidebar.tsx
Normal file
29
components/sidebar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { AllTools } from "./sidebar/all-tools";
|
||||
import { SearchBar } from "./sidebar/search-bar";
|
||||
import { Settings } from "./sidebar/settings";
|
||||
import { ToolGroups } from "./sidebar/tool-groups";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<nav className="flex w-72 flex-col">
|
||||
<div className="mt-px px-4">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mb-2 mt-4 px-2">
|
||||
<AllTools />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-2">
|
||||
<ToolGroups />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="justify-end p-2">
|
||||
<Settings />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
components/sidebar/all-tools.tsx
Normal file
20
components/sidebar/all-tools.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { singleTools } from "@/config/tools";
|
||||
|
||||
import { ToolLink } from "./tool-link";
|
||||
|
||||
export function AllTools() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ToolLink
|
||||
Icon={singleTools.allTools.Icon}
|
||||
shortTitle={singleTools.allTools.shortTitle}
|
||||
href={singleTools.allTools.href}
|
||||
highlight={pathname === singleTools.allTools.href ? "both" : "none"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
components/sidebar/search-bar.tsx
Normal file
64
components/sidebar/search-bar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
import { Button, ButtonProps } from "@/components/ui/button";
|
||||
import { Input, InputProps } from "@/components/ui/input";
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
export function SearchBar() {
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const search = () => {
|
||||
if (text.trim()) {
|
||||
router.push(`/search?q=${text.trim()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const changeText: InputProps["onChange"] = ({ currentTarget }) => setText(currentTarget.value);
|
||||
|
||||
const searchIfEnter: InputProps["onKeyDown"] = ({ code }) => {
|
||||
if (code === "Enter") {
|
||||
search();
|
||||
}
|
||||
};
|
||||
|
||||
const clearText: ButtonProps["onClick"] = () => {
|
||||
setText("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const clearIcon = useMemo(() => <icons.X className="p-1 text-muted-foreground" />, []);
|
||||
|
||||
const searchIcon = useMemo(
|
||||
() => <icons.Search className="-scale-x-100 p-1 text-muted-foreground" />,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="flex-1 pr-16 font-sans"
|
||||
value={text}
|
||||
onChange={changeText}
|
||||
onKeyDown={searchIfEnter}
|
||||
placeholder="Type to search for tools…"
|
||||
/>
|
||||
<div className="absolute right-1 flex gap-1">
|
||||
<Button className={cn("h-6 p-0", !text && "hidden")} variant="ghost" onClick={clearText}>
|
||||
{clearIcon}
|
||||
<span className="sr-only">Clear search text</span>
|
||||
</Button>
|
||||
<Button className="h-6 p-0" variant="ghost" onClick={search} aria-label="search">
|
||||
{searchIcon}
|
||||
<span className="sr-only">Search tools</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/sidebar/settings.tsx
Normal file
20
components/sidebar/settings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { singleTools } from "@/config/tools";
|
||||
|
||||
import { ToolLink } from "./tool-link";
|
||||
|
||||
export function Settings() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<ToolLink
|
||||
Icon={singleTools.settings.Icon}
|
||||
shortTitle={singleTools.settings.shortTitle}
|
||||
href={singleTools.settings.href}
|
||||
highlight={pathname === singleTools.settings.href ? "both" : "none"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
69
components/sidebar/tool-group.tsx
Normal file
69
components/sidebar/tool-group.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as Accordion from "@radix-ui/react-accordion";
|
||||
|
||||
import { ToolGroup as IToolGroup } from "@/config/tools";
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
import { ToolLink } from "./tool-link";
|
||||
|
||||
type Props = IToolGroup & {
|
||||
isOpend: boolean;
|
||||
};
|
||||
|
||||
// FIXME: css outline messed up
|
||||
export function ToolGroup({ Icon, title, href, tools, isOpend }: Props) {
|
||||
const pathname = usePathname();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onClick = useCallback(() => triggerRef.current?.click(), []);
|
||||
|
||||
const chevronIcon = useMemo(
|
||||
() => <icons.ChevronDown className="h-4 w-4 transition-transform duration-200" />,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion.AccordionItem value={href}>
|
||||
<Accordion.Header asChild>
|
||||
<div className="relative flex">
|
||||
<ToolLink
|
||||
className="flex-1"
|
||||
{...{ Icon, href, onClick }}
|
||||
shortTitle={title}
|
||||
highlight={
|
||||
pathname === href
|
||||
? "both"
|
||||
: !isOpend && pathname.startsWith(`${href}/`)
|
||||
? "indicatorOnly"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
<Accordion.Trigger
|
||||
ref={triggerRef}
|
||||
className="absolute right-0 flex h-10 w-10 items-center justify-center rounded transition-all duration-0 hover:bg-accent [&[data-state=open]>svg]:rotate-180"
|
||||
aria-label="toggle open/close state of the tool group"
|
||||
>
|
||||
{chevronIcon}
|
||||
</Accordion.Trigger>
|
||||
</div>
|
||||
</Accordion.Header>
|
||||
<Accordion.AccordionContent className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
||||
<ul>
|
||||
{Object.values(tools).map(tool => (
|
||||
<li className="mt-1" key={tool.href}>
|
||||
<ToolLink
|
||||
// -outline-offset-1: ugly hack for Chrome outlines
|
||||
className="pl-8 -outline-offset-1"
|
||||
{...tool}
|
||||
highlight={isOpend && pathname === tool.href ? "both" : "none"}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion.AccordionContent>
|
||||
</Accordion.AccordionItem>
|
||||
);
|
||||
}
|
||||
38
components/sidebar/tool-groups.tsx
Normal file
38
components/sidebar/tool-groups.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as Accordion from "@radix-ui/react-accordion";
|
||||
|
||||
import { toolGroups } from "@/config/tools";
|
||||
|
||||
import { ToolGroup } from "./tool-group";
|
||||
|
||||
const isGroupedTool = (path: string) =>
|
||||
Object.values(toolGroups)
|
||||
.map(({ href }) => href as string)
|
||||
.some(group => path.startsWith(`${group}/`));
|
||||
|
||||
export function ToolGroups() {
|
||||
const pathname = usePathname();
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGroupedTool(pathname)) {
|
||||
const group = `/${pathname.split("/")[1]}`;
|
||||
setExpandedGroups(prev => Array.from(new Set([...prev, group])));
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Accordion.Root type="multiple" value={expandedGroups} onValueChange={setExpandedGroups}>
|
||||
<ul className="space-y-1">
|
||||
{Object.values(toolGroups).map(group => (
|
||||
<li key={group.href}>
|
||||
<ToolGroup {...group} isOpend={expandedGroups.includes(group.href)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion.Root>
|
||||
);
|
||||
}
|
||||
38
components/sidebar/tool-link.tsx
Normal file
38
components/sidebar/tool-link.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
|
||||
import { Tool } from "@/config/tools";
|
||||
import { cn } from "@/lib/style";
|
||||
import { Indicator } from "@/components/indicator";
|
||||
|
||||
type Props = Pick<Tool, "Icon" | "shortTitle"> &
|
||||
Pick<LinkProps<unknown>, "className" | "href" | "onClick"> & {
|
||||
highlight: "both" | "indicatorOnly" | "none";
|
||||
};
|
||||
|
||||
function RawToolLink({ Icon, shortTitle: title, href, onClick, className, highlight }: Props) {
|
||||
const icon = useMemo(() => <Icon size={16} />, [Icon]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded hover:bg-accent",
|
||||
highlight === "both" && "bg-accent",
|
||||
className
|
||||
)}
|
||||
{...{ href, onClick }}
|
||||
>
|
||||
<span className={cn("invisible flex items-center", highlight !== "none" && "visible")}>
|
||||
<Indicator />
|
||||
</span>
|
||||
<span className="flex select-none items-center">
|
||||
{icon}
|
||||
<span className="ml-4">{title}</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const ToolLink = memo(RawToolLink);
|
||||
42
components/site-header.tsx
Normal file
42
components/site-header.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { icons } from "@/components/icons";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between px-4">
|
||||
<div className="flex items-baseline space-x-2.5">
|
||||
<Link className="text-lg" href="/">
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
<small className="text-xs">
|
||||
web clone of{" "}
|
||||
<a
|
||||
className="text-link hover:underline"
|
||||
href={siteConfig.links.devtoys}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
DevToys
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<a
|
||||
className="group rounded-md p-2"
|
||||
href={siteConfig.links.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div>
|
||||
<icons.GitHub className="h-6 w-6 group-hover:opacity-70" />
|
||||
<span className="sr-only">GitHub</span>
|
||||
</div>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
14
components/tailwind-indicator.tsx
Normal file
14
components/tailwind-indicator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">sm</div>
|
||||
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
components/theme-provider.tsx
Normal file
8
components/theme-provider.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
36
components/theme-toggle.tsx
Normal file
36
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { icons } from "@/components/icons";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const sunIcon = useMemo(
|
||||
() => (
|
||||
<icons.Sun className="h-7 w-7 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
),
|
||||
[]
|
||||
);
|
||||
const moonIcon = useMemo(
|
||||
() => (
|
||||
<icons.Moon className="absolute h-7 w-7 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="h-10 w-10 p-0"
|
||||
variant="ghost"
|
||||
onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}
|
||||
>
|
||||
{sunIcon}
|
||||
{moonIcon}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
21
components/tool-card.tsx
Normal file
21
components/tool-card.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Tool } from "@/config/tools";
|
||||
|
||||
export type ToolCardProps = Pick<Tool, "Icon" | "longTitle" | "description" | "href">;
|
||||
|
||||
export function ToolCard({ Icon, longTitle, description, href }: ToolCardProps) {
|
||||
return (
|
||||
<Link className="rounded" {...{ href }}>
|
||||
<div className="group flex h-80 w-44 flex-col items-center overflow-hidden rounded border bg-card p-5 pt-0 text-card-foreground hover:bg-card-hover">
|
||||
<div className="flex h-44 shrink-0 items-center">
|
||||
<div className="rounded bg-card-icon p-4 group-hover:bg-card-icon-hover">
|
||||
<Icon size={64} />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="w-full font-semibold">{longTitle}</h2>
|
||||
<p className="mt-1.5 w-full text-xs text-card-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
17
components/tool-cards.tsx
Normal file
17
components/tool-cards.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ToolCard, ToolCardProps } from "@/components/tool-card";
|
||||
|
||||
type Props = {
|
||||
tools: readonly ToolCardProps[];
|
||||
};
|
||||
|
||||
export function ToolCards({ tools }: Props) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-8">
|
||||
{tools.map(tool => (
|
||||
<li key={tool.href}>
|
||||
<ToolCard {...tool} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
39
components/ui/button.tsx
Normal file
39
components/ui/button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-hover",
|
||||
ghost: "hover:bg-accent",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 rounded-md py-2 px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
{...{ ref }}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
type="button"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
39
components/ui/editor.tsx
Normal file
39
components/ui/editor.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import MonacoEditor from "@monaco-editor/react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export type EditorProps = React.ComponentPropsWithoutRef<typeof MonacoEditor>;
|
||||
|
||||
/**
|
||||
* NOTE: This component maybe doesn't shrink according to the container component's width
|
||||
*
|
||||
* @see https://github.com/suren-atoyan/monaco-react/issues/346
|
||||
*
|
||||
*/
|
||||
export const Editor = React.forwardRef<HTMLTextAreaElement, EditorProps>(
|
||||
({ options, theme, ...props }, ref) => {
|
||||
const { theme: appTheme } = useTheme();
|
||||
const themeToUse = theme ?? (appTheme === "light" ? "light" : "vs-dark");
|
||||
|
||||
return (
|
||||
<MonacoEditor
|
||||
{...{ ref }}
|
||||
theme={themeToUse}
|
||||
// FIXME: why is `options` any?
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
options={{
|
||||
tabFocusMode: true,
|
||||
detectIndentation: false,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
...options, // NOTE: merge shallowly
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Editor.displayName = "Editor";
|
||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"border-b-1 flex h-9 w-full rounded border border-b-muted-foreground bg-input px-3 py-2 font-mono outline-none placeholder:text-muted-foreground hover:bg-input-hover focus:border-b-2 focus:border-b-indicator focus:bg-input-focus focus:pb-[7px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
spellCheck="false"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
21
components/ui/label.tsx
Normal file
21
components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
108
components/ui/select.tsx
Normal file
108
components/ui/select.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
import { icons } from "@/components/icons";
|
||||
import { Indicator } from "@/components/indicator";
|
||||
|
||||
export type SelectProps = React.ComponentPropsWithoutRef<typeof Select>;
|
||||
|
||||
export const Select = SelectPrimitive.Root;
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border bg-select px-2.5 py-1.5 placeholder:text-muted-foreground hover:bg-select-hover disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<icons.ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
export const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
{...{ ref, position }}
|
||||
className={cn(
|
||||
"relative z-50 overflow-hidden rounded-md border bg-select-content text-select-content-foreground shadow-md animate-in fade-in-80",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
export const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
{...{ ref }}
|
||||
className={cn("py-1.5 pl-8 pr-2 font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm px-2.5 py-1.5 outline-none hover:bg-select-item-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-0">
|
||||
<SelectPrimitive.ItemIndicator className="flex items-center">
|
||||
<Indicator />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
{...{ ref }}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
22
components/ui/separator.tsx
Normal file
22
components/ui/separator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
{...{ ref, decorative, orientation }}
|
||||
className={cn(
|
||||
"bg-separator",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
28
components/ui/switch.tsx
Normal file
28
components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>;
|
||||
|
||||
export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"group inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border border-muted-foreground bg-switch hover:bg-switch-hover disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-switch data-[state=checked]:border-transparent data-[state=checked]:bg-indicator data-[state=checked]:hover:bg-indicator-hover data-[state=checked]:disabled:hover:bg-indicator",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-3.5 w-3.5 rounded-full bg-foreground/80 shadow-lg transition-transform group-hover:h-4 group-hover:w-4 group-disabled:h-3.5 group-disabled:w-3.5 data-[state=checked]:translate-x-[22px] data-[state=unchecked]:translate-x-0.5 data-[state=checked]:bg-background"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
)
|
||||
);
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
20
components/ui/textarea.tsx
Normal file
20
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
{...{ ref }}
|
||||
className={cn(
|
||||
"border-b-1 flex w-full resize-none rounded border border-b-muted-foreground bg-textarea px-3 py-2 font-mono outline-none placeholder:text-muted-foreground hover:bg-textarea-hover focus:border-b-2 focus:border-b-indicator focus:bg-textarea-focus focus:pb-[7px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
spellCheck="false"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
25
components/ui/tooltip.tsx
Normal file
25
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/style";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
{...{ ref, sideOffset }}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-tooltip px-3 py-1.5 text-tooltip-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
Reference in New Issue
Block a user