This commit is contained in:
2026-03-04 17:35:14 +01:00
parent f73b56cdc4
commit db26e26495
113 changed files with 12433 additions and 169 deletions
+42
View File
@@ -0,0 +1,42 @@
import { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
componentRef?: RefObject<HTMLDivElement>;
};
/**
* # Toggle Component
* @className_wrapper twui-border
*/
export default function Border({
spacing,
componentRef,
...props
}: TWUI_BORDER_PROPS) {
return (
<div
{...props}
className={twMerge(
"relative flex items-center gap-2 border border-solid rounded-default",
"border-slate-200 dark:border-white/10",
spacing
? spacing == "normal"
? "px-3 py-2"
: spacing == "tight"
? "px-2 py-1"
: ""
: "px-3 py-2",
"twui-border",
props.className
)}
ref={componentRef}
>
{props.children}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
import React, { ComponentProps, ReactNode } from "react";
import Link from "../layout/Link";
import Divider from "../layout/Divider";
import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = {
title: string;
path: string;
};
type Props = {
excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
};
/**
* # TWUI Breadcrumbs
* @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button`
*/
export default function Breadcrumbs({
excludeRegexMatch,
linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
: null
);
React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname;
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
excludeRegexMatch,
skipHome,
});
setLinks(validPathLinks);
return function () {
setLinks(null);
};
}, []);
if (!links?.[1]) {
return <React.Fragment></React.Fragment>;
}
return (
<nav
className={twMerge(
"overflow-x-auto",
"twui-current-breadcrumb-wrapper"
)}
aria-label="Breadcrumb"
>
<Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
"twui-breadcrumbs-back-button",
backButtonProps?.className
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
beforeIcon={<ChevronLeft size={20} />}
/>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)}
title={
currentLinkProps?.title || linkObject.title
}
>
{currentTitle || linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)}
>
{currentLinkProps?.title ||
linkObject.title}
</Link>
{divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment>
);
}
})}
</Row>
</nav>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
export function twuiBreadcrumbsGenerateLinksFromUrl({
url,
excludeRegexMatch,
skipHome,
}: {
url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}
+72
View File
@@ -0,0 +1,72 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
variant?: "normal";
href?: string;
linkProps?: ComponentProps<typeof Link>;
noHover?: boolean;
elRef?: React.RefObject<HTMLDivElement>;
linkRef?: React.RefObject<HTMLAnchorElement>;
};
/**
* # General Card
* @className twui-card
* @className twui-card-link
*
* @info use the classname `nested-link` to prevent the card from being clickable when
* a link (or the target element with this calss) inside the card is clicked.
*/
export default function Card({
href,
variant,
linkProps,
noHover,
elRef,
linkRef,
...props
}: Props) {
const component = (
<div
ref={elRef}
{...props}
className={twMerge(
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
"border border-slate-200 dark:border-white/10 border-solid",
noHover ? "" : "twui-card",
props.className
)}
>
{props.children}
</div>
);
if (href) {
return (
<Link
ref={linkRef}
href={href}
{...linkProps}
className={twMerge(
"cursor-pointer",
"twui-card",
"twui-card-link",
linkProps?.className
)}
>
{component}
</Link>
);
}
return component;
}
+61
View File
@@ -0,0 +1,61 @@
import { ComponentProps, ReactNode } from "react";
import Stack from "../layout/Stack copy";
import Row from "../layout/Row";
import { Check, CheckCircle, CheckCircle2 } from "lucide-react";
import Span from "../layout/Span";
import { twMerge } from "tailwind-merge";
type BulletPoint = {
title: string;
icon?: ReactNode;
};
export type TWUI_CHECK_BULLET_POINTS_PROPS = ComponentProps<typeof Stack> & {
bulletPoints: BulletPoint[];
bulletWrapperProps?: ComponentProps<typeof Row>;
iconProps?: ComponentProps<typeof CheckCircle2>;
titleProps?: ComponentProps<typeof Span>;
};
/**
* # Check Bullet Points Component
* @className_wrapper twui-check-bullet-points-wrapper
*/
export default function CheckBulletPoints({
bulletPoints,
bulletWrapperProps,
iconProps,
titleProps,
...props
}: TWUI_CHECK_BULLET_POINTS_PROPS) {
return (
<Stack {...props} className={twMerge("gap-3", props.className)}>
{bulletPoints.map((bulletPoint, index) => {
return (
<Row
key={index}
{...bulletWrapperProps}
className={twMerge(
"gap-2 xl:flex-nowrap",
bulletWrapperProps?.className
)}
>
{bulletPoint.icon || (
<CheckCircle2
className="text-success min-w-[20px]"
size={20}
{...iconProps}
/>
)}
<Span
{...titleProps}
className={twMerge("", titleProps?.className)}
>
{bulletPoint.title}
</Span>
</Row>
);
})}
</Stack>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { Check, Copy } from "lucide-react";
import React, {
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
"data-title"?: string;
backgroundColor?: string;
singleBlock?: boolean;
language?: (typeof TWUIPrismLanguages)[number];
};
/**
* # CodeBlock
*
* @className `twui-code-block-wrapper`
* @className `twui-code-pre-wrapper`
* @className `twui-code-block-pre`
* @className `twui-code-block-header`
*/
export default function CodeBlock({
children,
wrapperProps,
backgroundColor,
singleBlock,
language,
...props
}: Props) {
const codeRef = React.useRef<HTMLDivElement>(null);
const [copied, setCopied] = React.useState(false);
const title = props?.["data-title"];
const finalBackgroundColor = backgroundColor || "#28272b";
return (
<div
{...wrapperProps}
className={twMerge(
"outline-[1px] outline-slate-200 dark:outline-white/10",
`rounded w-full transition-all items-start`,
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
"twui-code-block-wrapper",
wrapperProps?.className
)}
style={{
boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined,
backgroundColor: finalBackgroundColor,
...props.style,
}}
>
<Stack
className={twMerge(
"gap-0 w-full overflow-x-auto relative",
"max-h-[600px] overflow-y-auto"
)}
>
<Row
className={twMerge(
"w-full px-1 h-10 sticky top-0 py-2",
singleBlock ? "absolute !bg-transparent" : "",
"twui-code-block-header"
)}
style={{
backgroundColor: finalBackgroundColor,
}}
>
{title && <span className="text-white/70">{title}</span>}
<div className="ml-auto">
{copied ? (
<Row>
<span className="text-white text-xs twui-code-block-copied-text">
Copied!
</span>
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
<Check size={15} />
</div>
</Row>
) : (
<Button
variant="ghost"
color="gray"
beforeIcon={<Copy size={17} color="white" />}
className="!p-1 !bg-transparent opacity-50"
onClick={() => {
const content =
codeRef.current?.textContent;
if (!content) {
window.alert("No Content to copy");
return;
}
window.navigator.clipboard
.writeText(content)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
});
}}
title="Copy Code Snippet"
/>
)}
</div>
</Row>
{!singleBlock && (
<Divider className="!border-white/10 sticky top-10" />
)}
<div
className={twMerge(
`p-1 w-full [&_pre]:!bg-transparent`,
singleBlock ? "" : "-mt-1",
"twui-code-pre-wrapper"
)}
ref={codeRef as any}
>
<pre
{...props}
className={twMerge(
"!my-0 whitespace-pre-wrap",
language ? `language-${language}` : "",
"twui-code-block-pre",
props.className
)}
>
{children}
</pre>
</div>
</Stack>
</div>
);
}
+81 -9
View File
@@ -1,29 +1,101 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle";
import { Moon, Sun } from "lucide-react";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
iconWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultScheme?: "light" | "dark";
};
/**
* # Color Scheme Loader
* @className_wrapper twui-color-scheme-selector
*/
export default function ColorSchemeSelector({
toggleProps,
active: initialActive,
setActive: externalSetActive,
iconWrapperProps,
defaultScheme,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
toggleProps?: TWUI_TOGGLE_PROPS;
}) {
const [active, setActive] = React.useState(false);
}: Props) {
const [active, setActive] = React.useState(initialActive);
React.useEffect(() => {
const isDocumentDark =
document.documentElement.classList.contains("dark");
const isDocumentLight =
document.documentElement.classList.contains("light");
if (isDocumentDark) {
setActive(true);
return;
} else if (isDocumentLight) {
setActive(false);
return;
}
const existingTheme = localStorage.getItem("theme");
if (existingTheme === "dark") {
setActive(true);
} else if (existingTheme === "light") {
setActive(false);
} else if (defaultScheme) {
setActive(defaultScheme == "dark" ? false : true);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setActive(true);
} else if (typeof active == "undefined") {
setActive(false);
}
}, []);
React.useEffect(() => {
if (typeof active == "undefined") return;
if (active) {
document.documentElement.className = "dark";
localStorage.setItem("theme", "dark");
} else {
document.documentElement.className = "";
document.documentElement.className = "light";
localStorage.setItem("theme", "light");
}
}, [active]);
return (
<div
{...props}
className={twMerge("flex flex-row items-center", props.className)}
className={twMerge(
"flex flex-row items-center",
"twui-color-scheme-selector",
props.className
)}
>
<Toggle active={active} setActive={setActive} {...toggleProps} />
<button
title="Color Scheme Selector Button"
onClick={() => setActive(!active)}
className={twMerge(
"cursor-pointer hover:opacity-70 flex items-center justify-center"
)}
>
<div
{...iconWrapperProps}
className={twMerge(
"w-6 h-6 flex items-center justify-center",
iconWrapperProps?.className
)}
>
{active == false && <Sun />}
{active == true && <Moon />}
</div>
</button>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import { Copy, LucideProps } from "lucide-react";
import Button from "../layout/Button";
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
slugText: string;
justIcon?: boolean;
noIcon?: boolean;
title?: string;
outlined?: boolean;
successMsg?: string | ReactNode;
icon?: ReactNode;
iconProps?: LucideProps;
setToastOpen?: Dispatch<SetStateAction<boolean>>;
};
export default function CopySlug({
slugText,
justIcon,
noIcon,
title,
outlined,
successMsg,
iconProps,
icon,
setToastOpen,
...props
}: Props) {
return (
<Button
title={title || slugText}
size="smaller"
variant="ghost"
color="gray"
{...props}
onClick={(e) => {
navigator.clipboard.writeText(slugText).then(() => {
setToastOpen?.(false);
setTimeout(() => {
setToastOpen?.(true);
}, 100);
});
props.onClick?.(e);
}}
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
>
{noIcon
? null
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
{!justIcon && (title ? title : "Copy Slug")}
</Button>
);
}
+191
View File
@@ -0,0 +1,191 @@
import React, {
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [
"left",
"bottom-left",
"top-left",
"top",
"bottom",
"right",
"bottom-right",
"top-right",
"center",
] as const;
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
target: React.ReactNode;
contentWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
targetWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
debounce?: number;
openDebounce?: number;
hoverOpen?: boolean;
above?: boolean;
position?: (typeof TWUIDropdownContentPositions)[number];
topOffset?: number;
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
externalOpen?: boolean;
keepOpen?: boolean;
disableClickActions?: boolean;
};
/**
* # Toggle Component
* @className_wrapper twui-dropdown-wrapper
* @className_wrapper twui-dropdown-target
* @className_wrapper twui-dropdown-content
*
* @note use the class `cancel-link` to prevent popup open on click
*/
export default function Dropdown({
contentWrapperProps,
targetWrapperProps,
hoverOpen,
above,
debounce = 200,
openDebounce = 200,
target,
position = "center",
topOffset,
externalSetOpen,
keepOpen,
disableClickActions,
externalOpen,
...props
}: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(externalOpen);
let timeout: any;
let openTimeout: any;
const dropdownRef = React.useRef<HTMLDivElement>(null);
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
if (!closestWrapper) {
externalSetOpen?.(false);
return setOpen(false);
}
if (closestWrapper && closestWrapper !== dropdownRef.current) {
externalSetOpen?.(false);
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (keepOpen) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
React.useEffect(() => {
setOpen(externalOpen);
}, [externalOpen]);
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center relative",
"twui-dropdown-wrapper",
props.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
window.clearTimeout(openTimeout);
openTimeout = setTimeout(() => {
externalSetOpen?.(true);
setOpen(true);
}, openDebounce);
}}
onMouseLeave={(e) => {
if (!hoverOpen) return;
window.clearTimeout(openTimeout);
timeout = setTimeout(() => {
externalSetOpen?.(false);
setOpen(false);
}, debounce);
}}
onBlur={() => {
window.clearTimeout(timeout);
}}
ref={dropdownRef}
>
<div
onClick={(e) => {
const targetEl = e.target as HTMLElement | null;
if (targetEl?.closest?.(".cancel-link")) return;
if (disableClickActions) return;
externalSetOpen?.(!open);
setOpen(!open);
}}
className={twMerge(
"cursor-pointer",
"twui-dropdown-target",
targetWrapperProps?.className
)}
>
{target}
</div>
<div
{...contentWrapperProps}
className={twMerge(
"absolute z-10 mt-1",
position == "left"
? "left-[100%] top-[50%] -translate-y-[50%]"
: position == "right"
? "right-[100%] top-[50%] -translate-y-[50%]"
: position == "bottom-left"
? "left-0 top-[100%]"
: position == "bottom-right"
? "right-0 top-[100%]"
: position == "center"
? "left-[50%] -translate-x-[50%] top-[100%]"
: position == "top"
? "left-[50%] -translate-x-[50%] bottom-[100%]"
: "top-[100%]",
above ? "-translate-y-[120%]" : "",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeout);
}}
style={{
// top: `calc(100% + ${topOffset || 0}px)`,
...contentWrapperProps?.style,
}}
ref={dropdownContentRef}
>
{props.children}
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import React, { ComponentProps, PropsWithChildren, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "../layout/Stack";
import Border from "./Border";
import Center from "../layout/Center";
import Row from "../layout/Row";
import Span from "../layout/Span";
import Link from "../layout/Link";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
export type TWUIEmptyContentProps = ComponentProps<typeof Stack> & {
title: string;
url?: string;
linkProps?: ComponentProps<typeof Link>;
borderProps?: ComponentProps<typeof Border>;
textProps?: ComponentProps<typeof Span>;
contentWrapperProps?: ComponentProps<typeof Row>;
icon?: ReactNode;
};
/**
* # EmptyC ontent Component
* @className twui-empty-content
* @className twui-empty-content-border
* @className twui-empty-content-link
*/
export default function EmptyContent({
title,
url,
linkProps,
icon,
borderProps,
textProps,
contentWrapperProps,
...props
}: TWUIEmptyContentProps) {
const mainComponent = (
<Stack
{...props}
className={twMerge("w-full", "twui-empty-content", props.className)}
>
<Border
{...borderProps}
className={twMerge(
"w-full",
borderProps?.className,
"twui-empty-content-border"
)}
>
<Center>
<Row {...contentWrapperProps}>
{icon && <div className="opacity-50">{icon}</div>}
<Span
size="small"
{...textProps}
className={twMerge(
"opacity-70 text-foreground-light dark:text-foreground-dark",
textProps?.className
)}
>
{title}
</Span>
</Row>
</Center>
</Border>
</Stack>
);
if (url) {
return (
<Link
{...linkProps}
className={twMerge(
"w-full",
"twui-empty-content-link",
linkProps?.className
)}
href={url}
>
{mainComponent}
</Link>
);
}
return mainComponent;
}
+33
View File
@@ -0,0 +1,33 @@
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
import Link from "../layout/Link";
import { TwuiHeaderLink } from "./HeaderNav";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
export type TWUI_HEADER_LINK_PROPS = ComponentProps<typeof Link> & {
link: TwuiHeaderLink;
};
/**
* # Header Nav Component
* @className_wrapper twui-header-link
*/
export default function HeaderLink({ link, ...props }: TWUI_HEADER_LINK_PROPS) {
return (
<Link
href={link.url}
strict={link.strict}
{...props}
className={twMerge(
"grow p-2 hover:opacity-50",
"twui-header-link",
props.className
)}
>
<Row>
{link.icon}
{link.title}
</Row>
</Link>
);
}
+98
View File
@@ -0,0 +1,98 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
headerLinks: TwuiHeaderLink[];
customDropdowns?: {
url: string;
content: ReactNode;
}[];
};
export type TwuiHeaderLink = {
title: string;
url: string;
strict?: boolean;
dropdown?: ReactNode;
children?: TwuiHeaderLink[];
icon?: ReactNode;
};
/**
* # Header Nav Component
* @className twui-header-nav
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNav({
headerLinks,
customDropdowns,
...props
}: TWUI_HEADER_NAV_PROPS) {
React.useEffect(() => {
twuiAddActiveLinksFn({ selector: ".twui-header-nav-link-component a" });
}, []);
return (
<nav
{...props}
className={twMerge(
"twui-header-nav w-full xl:w-auto",
props.className
)}
>
<Row className="gap-x-2 gap-y-2 flex-col xl:flex-row items-start xl:items-stretch">
{headerLinks.map((link, index) => {
const targetCustomDropdown = customDropdowns?.find(
(d) => d.url == link.url
);
return (
<HeaderNavLinkComponent
link={link}
key={index}
dropdown={targetCustomDropdown?.content}
/>
);
})}
</Row>
</nav>
);
}
type AddActiveLinkParams = {
selector?: string;
wrapperEl?: HTMLElement;
};
export function twuiAddActiveLinksFn({
selector,
wrapperEl,
}: AddActiveLinkParams) {
(wrapperEl || document).querySelectorAll(selector || "a").forEach((ln) => {
const linkEl = ln as HTMLAnchorElement;
const isLinkStrict = linkEl.dataset.strict;
const linkAttr = linkEl.getAttribute("href");
if (window.location.pathname === "/" && linkAttr == "/") {
linkEl.classList.add("active");
} else if (
isLinkStrict &&
linkEl.getAttribute("href") === window.location.pathname
) {
linkEl.classList.add("active");
} else if (
linkAttr &&
window.location.pathname.startsWith(linkAttr) &&
!isLinkStrict
) {
linkEl.classList.add("active");
}
});
}
@@ -0,0 +1,141 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderLink from "./HeaderLink";
import { ChevronDown } from "lucide-react";
import Dropdown from "./Dropdown";
import { TwuiHeaderLink } from "./HeaderNav";
import Card from "./Card";
import Stack from "../layout/Stack";
import Button from "../layout/Button";
/**
* # Header Nav Main Link Component
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNavLinkComponent({
link,
dropdown,
}: {
link: TwuiHeaderLink;
dropdown?: ReactNode;
}) {
const isDropdown = dropdown || link.dropdown || link.children?.[0];
const mainLinkComponent = (
<Row className="gap-0 grow">
<HeaderLink link={link} strict={link.strict} />
{isDropdown && (
<ChevronDown
className={twMerge(
"hidden xl:flex xl:-ml-1",
"twui-header-nav-link-icon"
)}
size={20}
/>
)}
</Row>
);
const [showMobileDropdown, setShowMobileDropdown] = React.useState(false);
return (
<div
className={twMerge(
"relative w-full xl:w-auto [&_a.active]:font-bold",
"twui-header-nav-link-component"
)}
>
{isDropdown ? (
<React.Fragment>
<Stack className="flex xl:hidden w-full">
<Row className="w-full justify-between">
{mainLinkComponent}
<Button
variant="ghost"
onClick={() =>
setShowMobileDropdown(!showMobileDropdown)
}
title="Header Links Dropdown Button"
>
<ChevronDown
className={twMerge(
"twui-header-nav-link-icon !text-link dark:!text-white"
)}
size={20}
/>
</Button>
</Row>
{showMobileDropdown && (
<Stack className="w-full">
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"w-full p-0",
"twui-header-nav-link-dropdown"
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map(
(_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
}
)}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Stack>
)}
</Stack>
<Dropdown
target={mainLinkComponent}
position="center"
hoverOpen
className="hidden xl:flex"
>
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"min-w-[200px] mt-2 p-0",
"twui-header-nav-link-dropdown"
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map((_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
})}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Dropdown>
</React.Fragment>
) : (
mainLinkComponent
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import HtmlToReact from "html-to-react";
import { twMerge } from "tailwind-merge";
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
html: string;
componentRef?: React.RefObject<any>;
};
/**
* # HTML String to React Component
* @className_wrapper twui-html-react
*/
export default function HtmlToReactComponent({
html,
componentRef,
...props
}: TWUI_TOGGLE_PROPS) {
const htmlToReactParser = HtmlToReact.Parser();
const reactElement = htmlToReactParser.parse(html);
return (
<div
{...props}
className={twMerge("", props.className)}
ref={componentRef}
>
{reactElement}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import React, {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
import { twuiAddActiveLinksFn } from "./HeaderNav";
import Row from "../layout/Row";
import Divider from "../layout/Divider";
import Button from "../layout/Button";
export type TWUI_LINK_LIST_LINK_OBJECT = {
title?: string;
component?: ReactNode;
url?: string;
strict?: boolean;
icon?: ReactNode;
iconPosition?: "before" | "after";
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
linkType?: "button" | "link";
divider?: ReactNode;
onClick?: React.MouseEventHandler<HTMLElement>;
};
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
links: (
| TWUI_LINK_LIST_LINK_OBJECT
| TWUI_LINK_LIST_LINK_OBJECT[]
| undefined
)[];
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
divider?: boolean;
dividerComponent?: ReactNode;
linkType?: "button" | "link";
};
/**
* # Link List Component
* @description A component that renders a list of links.
* @className_wrapper twui-link-list
*/
export default function LinkList({
links,
linkProps,
buttonProps,
divider,
dividerComponent,
linkType,
...props
}: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
twuiAddActiveLinksFn({
wrapperEl: listRef.current || undefined,
selector: "a",
});
}, []);
return (
<div
ref={listRef}
{...props}
className={twMerge(
"flex flex-row items-center gap-1",
"twui-link-list",
props.className,
)}
>
{links
.flat()
.filter((ln) => Boolean(ln))
.map((link, index) => {
if (!link) return null;
if (link.divider)
return (
<React.Fragment key={index}>
{link.divider}
</React.Fragment>
);
const finalDivider =
index < links.length - 1 &&
(dividerComponent ? (
dividerComponent
) : divider ? (
<Divider />
) : undefined);
if (linkType == "button" || link.linkType == "button") {
return (
<React.Fragment key={index}>
<Button
title={link.title || "Link Button"}
variant="ghost"
{...buttonProps}
{...link.buttonProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
)}
onClick={(e) => {
link.onClick?.(e);
link.buttonProps?.onClick?.(e);
}}
>
<Row>
{link.icon}
{link.component || link.title}
</Row>
</Button>
{finalDivider}
</React.Fragment>
);
}
return (
<React.Fragment key={index}>
<Link
href={link.url}
title={link.title}
{...linkProps}
{...link.linkProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
link.linkProps?.className,
)}
strict={link.strict}
onClick={(e) => {
link.onClick?.(e);
link.linkProps?.onClick?.(e);
}}
>
<Row>
{!link.iconPosition ||
link.iconPosition == "before"
? link.icon
: null}
{link.component || link.title}
{link.iconPosition == "after"
? link.icon
: null}
</Row>
</Link>
{finalDivider}
</React.Fragment>
);
})}
</div>
);
}
+22 -10
View File
@@ -5,32 +5,44 @@ type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
size?: "small" | "normal" | "medium" | "large";
size?: "small" | "normal" | "medium" | "large" | "smaller";
svgClassName?: string;
};
export default function Loading({ size, ...props }: Props) {
/**
* # Loading Component
* @className_wrapper twui-loading
*/
export default function Loading({ size, svgClassName, ...props }: Props) {
const sizeClassName = (() => {
switch (size) {
case "small":
return "w-2 h-2";
case "normal":
case "smaller":
return "w-4 h-4";
case "small":
return "w-5 h-5";
case "normal":
return "w-6 h-6";
case "large":
return "w-8 h-8";
return "w-7 h-7";
default:
return "w-4 h-4";
return "w-6 h-6";
}
})();
return (
<div role="status" {...props}>
<div
role="status"
{...props}
className={twMerge(`twui-loading`, props.className)}
>
<svg
aria-hidden="true"
className={twMerge(
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600",
sizeClassName
"text-gray animate-spin dark:text-gray-dark fill-primary",
"dark:fill-white twui-loading",
sizeClassName,
svgClassName,
)}
viewBox="0 0 100 101"
fill="none"
+24
View File
@@ -0,0 +1,24 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-loading-block
*/
export default function LoadingBlock({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"bg-slate-200 dark:bg-white/10",
"rounded animate-pulse w-full h-[60px]",
"twui-loading-block",
props.className
)}
>
{props.children}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Center from "../layout/Center";
import Loading from "./Loading";
import Row from "../layout/Row";
import Span from "../layout/Span";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
loadingProps?: ComponentProps<typeof Loading>;
label?: string;
fixed?: boolean;
};
/**
* # Loading Overlay Component
* @className_wrapper twui-loading-overlay
*/
export default function LoadingOverlay({
loadingProps,
label,
fixed,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"top-0 left-0 w-full h-full z-[500]",
"bg-background-light/90 dark:bg-background-dark/90",
fixed ? "fixed" : "absolute",
props.className,
"twui-loading-overlay",
)}
>
<Center>
<Row>
<Loading {...loadingProps} />
{label && <Span>{label}</Span>}
</Row>
</Center>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import ModalComponent from "../(partials)/ModalComponent";
import PopoverComponent from "../(partials)/PopoverComponent";
import { twMerge } from "tailwind-merge";
export const TWUIPopoverStyles = [
"top",
"bottom",
"left",
"right",
"transform",
"bottom-left",
"bottom-right",
] as const;
export const TWUIPopoverTriggers = ["hover", "click"] as const;
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
target?: React.ReactNode;
targetRef?: React.RefObject<HTMLDivElement>;
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
targetWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
open?: boolean;
isPopover?: boolean;
position?: (typeof TWUIPopoverStyles)[number];
trigger?: (typeof TWUIPopoverTriggers)[number];
debounce?: number;
onClose?: () => any;
hoverOpen?: boolean;
};
/**
* # Modal Component
* @ID twui-modal-root
* @className twui-modal-content
* @className twui-modal
* @ID twui-popover-root
* @className twui-popover-content
* @className twui-popover-target
*/
export default function Modal(props: TWUI_MODAL_PROPS) {
const {
target,
targetRef,
targetWrapperProps,
open: existingOpen,
setOpen: existingSetOpen,
isPopover,
popoverReferenceRef,
trigger = "hover",
debounce = 500,
onClose,
hoverOpen,
} = props;
const [ready, setReady] = React.useState(false);
const [open, setOpen] = React.useState(existingOpen || false);
React.useEffect(() => {
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
const modalRoot = document.getElementById(IDName);
if (modalRoot) {
if (isPopover) {
modalRoot.style.zIndex = "1000";
}
setReady(true);
} else {
const newModalRootEl = document.createElement("div");
newModalRootEl.id = IDName;
document.body.appendChild(newModalRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
existingSetOpen?.(open);
if (open == false) onClose?.();
}, [open]);
React.useEffect(() => {
setOpen(existingOpen || false);
}, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
const popoverContentActiveRef = React.useRef(false);
let closeTimeout: any;
const popoverEnterFn = React.useCallback((e: any) => {
popoverTargetActiveRef.current = true;
popoverContentActiveRef.current = false;
setOpen(true);
props.onMouseEnter?.(e);
}, []);
const popoverLeaveFn = React.useCallback((e: any) => {
window.clearTimeout(closeTimeout);
closeTimeout = setTimeout(() => {
// if (popoverTargetActiveRef.current) {
// popoverTargetActiveRef.current = false;
// return;
// }
if (popoverContentActiveRef.current) {
popoverContentActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
props.onMouseLeave?.(e);
}, []);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-popover-content");
const closestTarget = targetEl.closest(".twui-popover-target");
if (closestTarget) return;
if (!closestWrapper) {
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (!isPopover) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
return (
<React.Fragment>
{target ? (
<div
{...targetWrapperProps}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
}}
ref={finalTargetRef}
onMouseEnter={
isPopover && (trigger === "hover" || hoverOpen)
? popoverEnterFn
: targetWrapperProps?.onMouseEnter
}
onMouseLeave={
isPopover && (trigger === "hover" || hoverOpen)
? popoverLeaveFn
: targetWrapperProps?.onMouseLeave
}
className={twMerge(
"twui-popover-target",
targetWrapperProps?.className
)}
>
{target}
</div>
) : null}
{ready ? (
isPopover ? (
<PopoverComponent
{...props}
open={open}
setOpen={setOpen}
targetElRef={finalPopoverReferenceRef}
debounce={debounce}
popoverTargetActiveRef={popoverTargetActiveRef}
popoverContentActiveRef={popoverContentActiveRef}
/>
) : (
<ModalComponent {...props} open={open} setOpen={setOpen} />
)
) : null}
</React.Fragment>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { ComponentProps, Dispatch, SetStateAction } from "react";
import _ from "lodash";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Button from "../layout/Button";
import EmptyContent from "./EmptyContent";
import Span from "../layout/Span";
type Props = ComponentProps<typeof Row> & {
page?: number;
setPage?: Dispatch<SetStateAction<number>>;
count?: number;
limit?: number;
};
/**
* # Pagination Component
* @param param0
* @returns
*/
export default function Pagination({
count,
page,
setPage,
limit,
...props
}: Props) {
if (!count || !page || !limit)
return (
<EmptyContent title={`count, page, and limit are all required`} />
);
const isLimit = limit * page >= count;
const pages = Math.ceil(count / limit);
return (
<Row
{...props}
className={twMerge(
"w-full justify-between flex-nowrap",
props.className
)}
>
{pages > 1 && (
<Button
title="Next Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev - 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
page == 1 ? "opacity-40 pointer-events-none" : ""
)}
>
<ChevronLeft size={20} />
</Button>
)}
<Row className={twMerge("gap-6 w-full flex-nowrap justify-center")}>
<Span size="small" variant="faded">
Page {page} / {pages}
</Span>
{pages > 1 && (
<Row
className={twMerge(
"flex-nowrap overflow-x-auto p-1 max-w-[90%]"
)}
>
{Array(pages)
.fill(0)
.map((p, index) => {
const isCurrent = page == index + 1;
return (
<Button
title={`Page ${index + 1}`}
onClick={() => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
setPage?.(index + 1);
}}
variant={
isCurrent ? "normal" : "outlined"
}
size="small"
color={isCurrent ? "primary" : "gray"}
className={twMerge(
"p-1 w-6 h-6 min-w-6"
)}
key={index}
>
{index + 1}
</Button>
);
})}
</Row>
)}
</Row>
{pages > 1 && (
<Button
title="Next Page Button"
onClick={() => {
window.scrollTo({ top: 0, behavior: "smooth" });
setPage?.((prev) => prev + 1);
}}
variant="outlined"
size="small"
className={twMerge(
"p-1",
isLimit ? "opacity-40 pointer-events-none" : ""
)}
>
<ChevronRight size={20} />
</Button>
)}
</Row>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-paper
*/
export default function Paper({
variant,
linkProps,
componentRef,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
variant?: "normal";
linkProps?: DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
componentRef?: RefObject<HTMLDivElement | null>;
}) {
return (
<div
{...props}
ref={componentRef as any}
className={twMerge(
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full",
"relative",
"twui-paper",
props.className
)}
>
{props.children}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import Modal, { TWUI_MODAL_PROPS } from "./Modal";
/**
* # Popover Component
*/
export default function Popover(props: TWUI_MODAL_PROPS) {
return <Modal {...props} isPopover />;
}
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = {
content: string;
refresh?: number;
};
/**
* # CodeBlock
*
* @className `twui-remote-code-block-wrapper`
*/
export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents();
React.useEffect(() => {
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
}).then((mdxSrc) => {
setMdxSource(mdxSrc);
});
}, [refresh]);
if (!mdxSource) {
return null;
}
return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { twMerge } from "tailwind-merge";
import Input, { InputProps } from "../form/Input";
import Button from "../layout/Button";
import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react";
import React, { DetailedHTMLProps } from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
dispatch?: (value?: string) => void;
changeHandler?: (value?: string) => void;
delay?: number;
inputProps?: InputProps<KeyType>;
buttonProps?: DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
loading?: boolean;
placeholder?: string;
componentRef?: React.RefObject<HTMLInputElement | null>;
};
/**
* # Search Component
* @className_wrapper twui-search-wrapper
* @className_circle twui-search-input
* @className_circle twui-search-button
*/
export default function Search<KeyType extends string>({
dispatch,
changeHandler,
delay = 500,
inputProps,
buttonProps,
loading,
placeholder,
componentRef,
...props
}: SearchProps<KeyType>) {
const [input, setInput] = React.useState(
props.defaultValue?.toString() || ""
);
React.useEffect(() => {
clearTimeout(timeout);
timeout = setTimeout(() => {
dispatch?.(input);
changeHandler?.(input);
}, delay);
}, [input]);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
// React.useEffect(() => {
// if (props.autoFocus) {
// inputRef.current?.focus();
// }
// }, []);
return (
<Row
{...props}
className={twMerge(
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
"twui-search-wrapper",
props?.className
)}
>
<Input
type="search"
placeholder={placeholder || "Search"}
{...inputProps}
value={input}
onChange={(e) => setInput(e.target.value)}
className={twMerge(
"rounded-r-none!",
"twui-search-input",
inputProps?.className
)}
wrapperProps={{
className: "rounded-r-none!",
}}
componentRef={inputRef}
/>
<Button
loadingProps={{ size: "small" }}
{...buttonProps}
variant="outlined"
color="gray"
className={twMerge(
"rounded-l-none! ml-[1px]",
"twui-search-button",
buttonProps?.className
)}
onClick={() => {
dispatch?.(input);
changeHandler?.(input);
}}
title="Search Button"
loading={loading}
>
<SearchIcon
className="text-slate-800 dark:text-white"
size={20}
/>
</Button>
</Row>
);
}
@@ -0,0 +1,24 @@
import React, { DetailedHTMLProps, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren &
DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
/**
* # Single Line CodeBlock
*/
export default function SingleLineCodeBlock({ children, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"[&_.twui-code-block-header]:absolute [&_.twui-code-block-header]:!bg-transparent",
"[&_.twui-code-block-header]:mt-2 [&_.twui-code-block-header]:pr-3 [&_.twui-divider]:hidden",
"[&_pre]:!pr-14 [&_.twui-code-block-copied-text]:!hidden",
props.className
)}
>
{children}
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { LucideProps, Star } from "lucide-react";
import React, {
DetailedHTMLProps,
ForwardRefExoticComponent,
HTMLAttributes,
RefAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
type StarProps = {
total?: number;
value?: number;
size?: number;
starProps?: LucideProps;
allowRating?: boolean;
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
changeHandler?: (value: number) => void;
};
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> &
StarProps;
let timeout: any;
/**
* # Star Rating Component
* @className_wrapper twui-star-rating
*/
export default function StarRating({
total = 5,
value = 0,
size,
starProps,
allowRating,
setValueExternal,
changeHandler,
...props
}: TWUI_STAR_RATING_PROPS) {
const totalArray = Array(total).fill(null);
const [finalValue, setFinalValue] = React.useState(value);
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false);
React.useEffect(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setValueExternal?.(finalValue);
}, 500);
}, [selectedStarValue]);
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center gap-0 -ml-[2px]",
"twui-star-rating",
props.className,
)}
onMouseEnter={() => {
sectionHovered.current = true;
}}
onMouseLeave={() => {
sectionHovered.current = false;
}}
>
{totalArray.map((_, index) => {
const isActive = index + 1 <= finalValue;
return (
<StarComponent
{...{
total,
value,
size,
starProps,
index,
allowRating,
finalValue,
setFinalValue,
starClicked,
selectedStarValue,
sectionHovered,
setSelectedStarValue,
isActive,
changeHandler,
}}
key={index}
/>
);
})}
</div>
);
}
function StarComponent({
size = 20,
starProps,
index,
allowRating,
setFinalValue,
starClicked,
sectionHovered,
setSelectedStarValue,
selectedStarValue,
isActive,
changeHandler,
}: StarProps & {
index: number;
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
starClicked: React.MutableRefObject<boolean>;
sectionHovered: React.MutableRefObject<boolean>;
selectedStarValue: number;
isActive: boolean;
}) {
return (
<div
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
onMouseEnter={() => {
if (!allowRating) return;
setFinalValue(index + 1);
}}
onMouseLeave={() => {
if (!allowRating) return;
setTimeout(() => {
if (sectionHovered.current) {
return;
}
if (!starClicked.current) {
setFinalValue(0);
}
if (selectedStarValue) {
setFinalValue(selectedStarValue);
}
}, 200);
}}
onClick={() => {
if (!allowRating) return;
starClicked.current = true;
setSelectedStarValue(index + 1);
changeHandler?.(index + 1);
}}
>
<Star
size={size}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
// allowRating &&
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className,
)}
{...starProps}
/>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import React from "react";
import EmptyContent from "./EmptyContent";
import { twMerge } from "tailwind-merge";
type Props = {
data?: { [k: string]: any }[];
};
export default function Table({ data }: Props) {
if (!data || data.length === 0) {
return (
<EmptyContent
title="No results"
borderProps={{ className: "!p-2" }}
/>
);
}
const headers = Object.keys(data[0]);
return (
<div className={twMerge("overflow-x-auto w-full")}>
<table
className={twMerge(
"min-w-full divide-y divide-gray dark:divide-gray-dark"
)}
>
<thead className="bg-gray dark:bg-gray-dark">
<tr>
{headers.map((header) => (
<th
key={header}
className={twMerge(
"px-3 py-2 text-left opacity-50",
"font-semibold"
)}
title={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody
className={twMerge(
"bg-background-light dark:bg-background-dark",
"divide-y divide-gray dark:divide-gray-dark"
)}
>
{data.map((row, index) => (
<tr
key={index}
className={twMerge(
"hover:bg-gray/20 dark:hover:bg-gray-dark/10"
)}
>
{headers.map((header) => (
<td
key={`${header}-${index}`}
className={twMerge(
"px-3 py-2 whitespace-nowrap text-foreground-light",
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
"overflow-ellipsis"
)}
title={row[header]}
>
{row[header]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Border from "./Border";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
export type TWUITabsObject = {
title: string;
value?: string;
content?: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
tabsBorderProps?: React.ComponentProps<typeof Border>;
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
centered?: boolean;
debounce?: number;
/**
* React Component to display when switching
*/
switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
changeHandler?: (value: TWUITabsObject) => void;
defaultValue?: string | null;
hrefUpdate?: boolean;
};
/**
* # Tabs Component
* @className twui-tabs-wrapper
* @className twui-tab-buttons
* @className twui-tab-button-active
* @className twui-tab-buttons-wrapper
* @className twui-tab-buttons-container
* @className twui-tabs-border
*/
export default function Tabs({
tabsContentArray,
tabsBorderProps,
tabsButtonsWrapperProps,
centered,
debounce = 100,
switchComponent,
setActiveValue: existingSetActiveValue,
changeHandler,
defaultValue,
hrefUpdate,
...props
}: TWUI_TOGGLE_PROPS) {
const finalTabsContentArray = tabsContentArray
.flat()
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
const values = finalTabsContentArray.map(
(obj) => obj.value || twuiSlugify(obj.title),
);
const defaultActiveObj = finalTabsContentArray.find(
(ctn) => ctn.defaultActive,
);
const [activeValue, setActiveValue] = React.useState(
defaultValue
? defaultValue
: defaultActiveObj
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
: values[0] || undefined,
);
const [ready, setReady] = React.useState(false);
const targetContent = finalTabsContentArray.find(
(ctn) =>
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
);
React.useEffect(() => {
if (!ready) return;
existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) {
changeHandler?.(targetContent);
if (hrefUpdate) {
const url = new URL(window.location.href);
url.searchParams.set("tab", activeValue);
window.history.pushState({}, "", url);
}
}
}, [activeValue]);
React.useEffect(() => {
if (hrefUpdate) {
const url = new URL(window.location.href);
const activeTab = url.searchParams.get("tab");
if (activeTab && activeValue !== activeTab) {
setActiveValue(undefined);
setActiveValue(activeTab);
}
setTimeout(() => {
setReady(true);
}, 500);
} else {
setReady(true);
}
}, []);
return (
<Stack
{...props}
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
>
<div
{...tabsButtonsWrapperProps}
className={twMerge(
"w-full",
"twui-tab-buttons-wrapper",
tabsButtonsWrapperProps?.className,
)}
>
<Border
className="p-0 w-full overflow-hidden twui-tabs-border"
{...tabsBorderProps}
>
<Row
className={twMerge(
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
centered && "justify-center",
"twui-tab-buttons-container",
)}
>
{values.map((value, index) => {
const targetObject = finalTabsContentArray.find(
(ctn) =>
ctn.value == value ||
twuiSlugify(ctn.title) == value,
);
const isActive = value == activeValue;
return (
<span
className={twMerge(
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
isActive
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer",
"twui-tab-buttons",
)}
onClick={() => {
setActiveValue(undefined);
setTimeout(() => {
setActiveValue(value);
}, debounce);
}}
key={index}
>
{targetObject?.title}
</span>
);
})}
</Row>
</Border>
</div>
{activeValue ? targetContent?.content : switchComponent || null}
</Stack>
);
}
+104
View File
@@ -0,0 +1,104 @@
import React, { PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
export type TWUITabsObject = {
title: string;
value: string;
content: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
color?: "normal" | "secondary" | "error" | "success" | "gray";
variant?: "normal" | "outlined" | "ghost";
href?: string;
newTab?: boolean;
linkProps?: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
};
/**
* # Tabs Component
* @className twui-tag
* @className twui-tag-primary-outlined
*/
export default function Tag({
color,
variant,
children,
href,
newTab,
linkProps,
...props
}: TWUI_TOGGLE_PROPS) {
const mainComponent = (
<div
{...props}
className={twMerge(
"text-xs px-2 py-0.5 rounded-full outline-0",
"text-center flex items-center justify-center",
color == "secondary"
? "bg-secondary text-white outline-secbg-secondary"
: color == "success"
? "bg-success outline-success text-white"
: color == "error"
? "bg-orange-700 outline-orange-700"
: color == "gray"
? twMerge(
"bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
"text-slate-800 dark:text-white"
)
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
variant == "outlined"
? "!bg-transparent outline-1 " +
(color == "secondary"
? "text-secondary"
: color == "success"
? "text-success dark:text-success-dark"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
: variant == "ghost"
? "!bg-transparent outline-none border-none " +
(color == "secondary"
? "text-secondary"
: color == "success"
? "text-success dark:text-success-dark"
: color == "error"
? "text-orange-700"
: color == "gray"
? "text-slate-700 dark:text-white/80"
: "text-primary dark:text-primary-dark")
: "",
"twui-tag",
props.className
)}
>
{children}
</div>
);
if (href) {
return (
<a
href={href}
target={newTab ? "_blank" : undefined}
{...linkProps}
className={twMerge("hover:opacity-80", linkProps?.className)}
>
{mainComponent}
</a>
);
}
return mainComponent;
}
+117
View File
@@ -0,0 +1,117 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Card from "./Card";
import { X } from "lucide-react";
import ReactDOM from "react-dom";
import Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
export type TWUIToastProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
closeDispatch?: (open?: boolean) => void;
closeDelay?: number;
color?: (typeof ToastStyles)[number];
};
let interval: any;
let timeout: any;
/**
* # Toast Component
* @className twui-toast-root
* @className twui-toast
* @className twui-toast-success
* @className twui-toast-error
*/
export default function Toast({
open,
setOpen,
closeDelay = 4000,
color,
closeDispatch,
...props
}: TWUIToastProps) {
const [ready, setReady] = React.useState(false);
const IDName = "twui-toast-root";
React.useEffect(() => {
const toastRoot = document.getElementById(IDName);
if (toastRoot) {
setReady(true);
} else {
const newToastRootEl = document.createElement("div");
newToastRootEl.id = IDName;
document.body.appendChild(newToastRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
if (!ready || !open) return;
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
return function () {
setOpen?.(false);
closeDispatch?.(open);
};
}, [ready, open]);
if (!ready) return null;
if (!open) return null;
return ReactDOM.createPortal(
<Card
{...props}
className={twMerge(
"fixed bottom-4 right-4 z-[250] border-none",
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
color == "success"
? "bg-success-dark dark:bg-success-dark twui-toast-success"
: color == "error"
? "bg-error dark:bg-error-dark twui-toast-error"
: "",
props.className,
"twui-toast",
)}
onMouseEnter={() => {
window.clearTimeout(timeout);
}}
onMouseLeave={(e) => {
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
}}
>
<Span
className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer",
"text-white",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen?.(false);
closeDispatch?.(open);
}}
>
<X size={15} />
</Span>
<Span className={twMerge("text-white! font-semibold")}>
{props.children}
</Span>
</Card>,
document.getElementById(IDName) as HTMLElement,
);
}
+16 -12
View File
@@ -6,7 +6,7 @@ export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
circleProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
@@ -36,17 +36,21 @@ export default function Toggle({
)}
onClick={() => setActive?.(!active)}
>
<div
{...circleProps}
className={twMerge(
"w-3.5 h-3.5 rounded-full ",
active
? "bg-blue-600 dark:bg-blue-500"
: "bg-slate-300 dark:bg-white/40",
"twui-toggle-circle",
circleProps?.className
)}
></div>
{typeof active == "undefined" ? (
<div className="w-3.5 h-3.5 twui-toggle-circle"></div>
) : (
<div
{...circleProps}
className={twMerge(
"w-3.5 h-3.5 rounded-full ",
active
? "bg-blue-600 dark:bg-blue-500"
: "bg-slate-300 dark:bg-white/40",
"twui-toggle-circle",
circleProps?.className
)}
></div>
)}
</div>
);
}
@@ -0,0 +1,81 @@
import { Send, X } from "lucide-react";
import React, { Dispatch, SetStateAction } from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
type Props = {
streamRes: string;
setStreamRes: Dispatch<SetStateAction<string>>;
setPrompt: Dispatch<SetStateAction<string>>;
loading: boolean;
promptFn: (prompt: string) => void;
history: ChatCompletionMessageParam[];
prompt: string;
currentPromptRef: React.MutableRefObject<string>;
promptInputRef: React.RefObject<HTMLTextAreaElement>;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4">
{streamRes.match(/./) && (
<React.Fragment>
<Button
title="Clear AI Result"
variant="ghost"
size="smaller"
color="gray"
className="px-0"
beforeIcon={<X size={20} />}
onClick={() => {
setStreamRes("");
}}
/>
<CopySlug
slugText={streamRes}
justIcon
iconProps={{ size: 18 }}
title="Copy Content"
/>
</React.Fragment>
)}
</Row>
<Row>
<AIPromptHistoryModal history={history} />
</Row>
<Row>
<Button
title="Send Prompt"
beforeIcon={<Send size={20} />}
loading={loading}
className="p-2"
onClick={() => {
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}}
loadingProps={{ size: "smaller" }}
/>
</Row>
</Row>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
<Stack className="w-full">
{currentPromptRef.current && (
<Row className="w-full justify-end">
<Card className="py-1.5 px-2.5 text-xs">
<Row>
<Span>{currentPromptRef.current}</Span>
<MessageCircleMore
size={15}
opacity={0.5}
className="-mt-px"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
loading={loading}
/>
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={
placeholder ||
(model ? `Prompt ${model}` : "Prompt AI")
}
wrapperProps={{ className: "outline-none" }}
wrapperWrapperProps={{ className: "w-full" }}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}
}}
componentRef={promptInputRef}
// autoFocus
/>
<AIPromptActionSection
loading={loading}
promptFn={promptFn}
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
prompt={prompt}
setPrompt={setPrompt}
currentPromptRef={currentPromptRef}
promptInputRef={promptInputRef as any}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,99 @@
import React from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Modal from "../Modal";
import Button from "../../layout/Button";
import Stack from "../../layout/Stack";
import H2 from "../../layout/H2";
import Span from "../../layout/Span";
import Divider from "../../layout/Divider";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
type Props = {
history: ChatCompletionMessageParam[];
};
export default function AIPromptHistoryModal({ history }: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="outlined"
>
View History
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
>
<Stack className="gap-10 w-full">
<Stack className="gap-1">
<H2 className="!text-xl m-0">Chat History</H2>
<Span className="text-xs">
AI chat history for this session.
</Span>
</Stack>
<Divider />
{history.map((hst, index) => {
if (hst.role == "user") {
return (
<Row
key={index}
className="w-full items-start justify-end"
>
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light"
)}
>
{hst.content?.toString()}
</Card>
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
</Row>
);
}
return (
<Row
key={index}
className="w-full items-start flex-nowrap"
>
<Stack>
<Border
className={twMerge(
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
"dark:bg-background-dark"
)}
>
<Bot />
</Border>
</Stack>
<Card className="grow overflow-x-auto xl:p-8">
<MarkdownEditorPreviewComponent
value={hst.content?.toString() || ""}
maxHeight="none"
wrapperProps={{
className:
"border-none p-0 ai-response-content w-full",
}}
/>
</Card>
</Row>
);
})}
</Stack>
</Modal>
);
}
@@ -0,0 +1,53 @@
import Divider from "../../layout/Divider";
import Stack from "../../layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
import { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
loading?: boolean;
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
loading,
}: Props) {
const responseContentRef = React.useRef<HTMLDivElement>(null);
const isContentInterrupted = React.useRef(false);
React.useEffect(() => {
if (isContentInterrupted.current) return;
if (responseContentRef.current) {
responseContentRef.current.scrollTop =
responseContentRef.current.scrollHeight;
}
}, [streamRes]);
if (loading || !streamRes?.match(/./)) return null;
return (
<Stack className="w-full">
<MarkdownEditorPreviewComponent
value={streamRes}
maxHeight="40vh"
wrapperProps={{
className: "border-none p-0 ai-response-content",
componentRef: responseContentRef as any,
onMouseEnter: () => {
isContentInterrupted.current = true;
},
onMouseLeave: () => {
isContentInterrupted.current = false;
},
}}
/>
<Divider />
</Stack>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { LucideProps } from "lucide-react";
import * as icons from "lucide-react";
import React from "react";
export type TWUILucideIconName = keyof typeof icons;
export type TWUILucideIconProps = LucideProps & {
name: TWUILucideIconName;
};
export default function LucideIcon({ name, ...props }: TWUILucideIconProps) {
const IconComponent = icons[name] as any;
if (!IconComponent) {
console.warn(`Lucide icon "${name}" not found`);
return null;
}
return <IconComponent {...props} />;
}