Package library as @moduletrace/twui npm package
- Move components/ to src/ and compile to dist/ with tsc - Add root barrel export plus deep-import subpath exports and base.css export - Post-compile step: copy base.css, add .js import extensions, mark client modules - Add publish.sh and .npmrc for the Gitea npm registry - Fix type errors, guard TinyMCE env access, drop ace-builds type dependency - Rename clashing props type names, remove Next.js scaffolding and unused deps Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import type { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { type ComponentProps, type 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;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type 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;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import Stack from "../layout/Stack";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React, {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import type { TWUIButtonSizeType } from "../layout/Button";
|
||||
|
||||
const SizeMap: Record<
|
||||
TWUIButtonSizeType,
|
||||
{ icon: number; className: string }
|
||||
> = {
|
||||
smaller: { icon: 16, className: "w-4 h-4" },
|
||||
small: { icon: 20, className: "w-5 h-5" },
|
||||
normal: { icon: 24, className: "w-6 h-6" },
|
||||
large: { icon: 28, className: "w-7 h-7" },
|
||||
larger: { icon: 32, className: "w-8 h-8" },
|
||||
};
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
active?: boolean;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
iconWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultScheme?: "light" | "dark";
|
||||
/**
|
||||
* Icon size. Matches the twui `Button` size scale.
|
||||
* @default "normal"
|
||||
*/
|
||||
size?: TWUIButtonSizeType;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Color Scheme Loader
|
||||
* @className_wrapper twui-color-scheme-selector
|
||||
*/
|
||||
export default function ColorSchemeSelector({
|
||||
active: initialActive,
|
||||
setActive: externalSetActive,
|
||||
iconWrapperProps,
|
||||
defaultScheme,
|
||||
size = "normal",
|
||||
...props
|
||||
}: Props) {
|
||||
const { icon: iconSize, className: sizeClassName } = SizeMap[size];
|
||||
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");
|
||||
} 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;
|
||||
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
document.documentElement.classList.add(active ? "dark" : "light");
|
||||
}, [active]);
|
||||
|
||||
/**
|
||||
* Only an explicit toggle is persisted, so visitors who never choose
|
||||
* keep following their OS preference.
|
||||
*/
|
||||
function toggleScheme() {
|
||||
const next = !active;
|
||||
setActive(next);
|
||||
externalSetActive?.(next);
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center",
|
||||
"twui-color-scheme-selector",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
title="Color Scheme Selector Button"
|
||||
onClick={toggleScheme}
|
||||
className={twMerge(
|
||||
"cursor-pointer hover:opacity-70 flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...iconWrapperProps}
|
||||
className={twMerge(
|
||||
"flex items-center justify-center",
|
||||
sizeClassName,
|
||||
iconWrapperProps?.className,
|
||||
)}
|
||||
>
|
||||
{active == false && <Sun size={iconSize} />}
|
||||
{active == true && <Moon size={iconSize} />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { Copy, type 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
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);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [contentStyle, setContentStyle] = React.useState<React.CSSProperties>(
|
||||
{},
|
||||
);
|
||||
|
||||
const timeoutRef = React.useRef<any>(null);
|
||||
const openTimeoutRef = React.useRef<any>(null);
|
||||
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
if (dropdownContentRef.current?.contains(targetEl)) return;
|
||||
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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Content is portaled to `document.body` so it renders above
|
||||
* everything else on the page. Position is computed from the
|
||||
* wrapper's bounding rect and kept in sync on scroll/resize.
|
||||
*/
|
||||
const updateContentPosition = React.useCallback(() => {
|
||||
const wrapperEl = dropdownRef.current;
|
||||
if (!wrapperEl) return;
|
||||
|
||||
const rect = wrapperEl.getBoundingClientRect();
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
let style: React.CSSProperties;
|
||||
|
||||
switch (position) {
|
||||
case "left":
|
||||
style = {
|
||||
left: rect.right,
|
||||
top: centerY,
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
break;
|
||||
case "right":
|
||||
style = {
|
||||
right: viewportWidth - rect.left,
|
||||
top: centerY,
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
break;
|
||||
case "bottom-left":
|
||||
style = { left: rect.left, top: rect.bottom };
|
||||
break;
|
||||
case "bottom-right":
|
||||
style = { right: viewportWidth - rect.right, top: rect.bottom };
|
||||
break;
|
||||
case "top":
|
||||
style = {
|
||||
left: centerX,
|
||||
bottom: viewportHeight - rect.top,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
break;
|
||||
default:
|
||||
style = {
|
||||
left: centerX,
|
||||
top: rect.bottom,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
above &&
|
||||
style.top !== undefined &&
|
||||
position !== "left" &&
|
||||
position !== "right"
|
||||
) {
|
||||
delete style.top;
|
||||
style.bottom = viewportHeight - rect.top;
|
||||
}
|
||||
|
||||
setContentStyle(style);
|
||||
}, [position, above]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
updateContentPosition();
|
||||
|
||||
window.addEventListener("scroll", updateContentPosition, true);
|
||||
window.addEventListener("resize", updateContentPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updateContentPosition, true);
|
||||
window.removeEventListener("resize", updateContentPosition);
|
||||
};
|
||||
}, [open, updateContentPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center relative",
|
||||
"twui-dropdown-wrapper",
|
||||
props.className,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
window.clearTimeout(openTimeoutRef.current);
|
||||
|
||||
openTimeoutRef.current = setTimeout(() => {
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
}, openDebounce);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!hoverOpen) return;
|
||||
|
||||
window.clearTimeout(openTimeoutRef.current);
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
externalSetOpen?.(false);
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}}
|
||||
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>
|
||||
|
||||
{mounted &&
|
||||
ReactDOM.createPortal(
|
||||
<div
|
||||
{...contentWrapperProps}
|
||||
className={twMerge(
|
||||
"fixed z-[1000]",
|
||||
above ? "mb-1" : "mt-1",
|
||||
open ? "flex" : "hidden",
|
||||
"twui-dropdown-content",
|
||||
contentWrapperProps?.className,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}}
|
||||
style={{
|
||||
...contentStyle,
|
||||
...contentWrapperProps?.style,
|
||||
}}
|
||||
ref={dropdownContentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { 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 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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "../layout/Link";
|
||||
import type { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type 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, { type 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 type { 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,32 @@
|
||||
import type { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUI_HTML_TO_REACT_PROPS = Omit<
|
||||
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
|
||||
"dangerouslySetInnerHTML"
|
||||
> & {
|
||||
html: string;
|
||||
componentRef?: React.RefObject<any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # HTML String to React Component
|
||||
* Renders the HTML as-is, so only pass trusted or already-escaped HTML.
|
||||
* Uses `dangerouslySetInnerHTML` rather than an HTML-to-React parser so
|
||||
* it works in both server and browser bundles.
|
||||
* @className_wrapper twui-html-react
|
||||
*/
|
||||
export default function HtmlToReactComponent({
|
||||
html,
|
||||
componentRef,
|
||||
...props
|
||||
}: TWUI_HTML_TO_REACT_PROPS) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge("twui-html-react", props.className)}
|
||||
ref={componentRef}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type 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 TWUILink =
|
||||
| TWUI_LINK_LIST_LINK_OBJECT
|
||||
| TWUI_LINK_LIST_LINK_OBJECT[]
|
||||
| undefined;
|
||||
|
||||
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
links: TWUILink[];
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
size?: "small" | "normal" | "medium" | "large" | "smaller";
|
||||
svgClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Loading Component
|
||||
* @className_wrapper twui-loading
|
||||
*/
|
||||
export default function Loading({ size, svgClassName, ...props }: Props) {
|
||||
const sizeClassName = (() => {
|
||||
switch (size) {
|
||||
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-7 h-7";
|
||||
|
||||
default:
|
||||
return "w-6 h-6";
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
{...props}
|
||||
className={twMerge(`twui-loading`, props.className)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={twMerge(
|
||||
"text-gray animate-spin dark:text-gray-dark fill-primary",
|
||||
"dark:fill-white twui-loading",
|
||||
sizeClassName,
|
||||
svgClassName,
|
||||
)}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { type DetailedHTMLProps, type 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;
|
||||
no_cancel_button?: 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
|
||||
* @className twui-modal-content
|
||||
* @className twui-modal-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,
|
||||
no_cancel_button,
|
||||
} = 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);
|
||||
|
||||
const closeTimeoutRef = React.useRef<any>(null);
|
||||
|
||||
const popoverEnterFn = React.useCallback((e: any) => {
|
||||
popoverTargetActiveRef.current = true;
|
||||
popoverContentActiveRef.current = false;
|
||||
window.clearTimeout(closeTimeoutRef.current);
|
||||
setOpen(true);
|
||||
props.onMouseEnter?.(e);
|
||||
}, []);
|
||||
|
||||
const popoverLeaveFn = React.useCallback((e: any) => {
|
||||
window.clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
if (popoverContentActiveRef.current) {
|
||||
popoverContentActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
props.onMouseLeave?.(e);
|
||||
}, []);
|
||||
|
||||
const target_class_name = isPopover
|
||||
? "twui-popover-target"
|
||||
: "twui-modal-target";
|
||||
const content_class_name = isPopover
|
||||
? "twui-popover-content"
|
||||
: "twui-modal-content";
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (!isPopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const closestWrapper = targetEl.closest(`.${content_class_name}`);
|
||||
const closestTarget = targetEl.closest(`.${target_class_name}`);
|
||||
|
||||
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(
|
||||
target_class_name,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { 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);
|
||||
|
||||
const next_button = (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full justify-between md: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>
|
||||
)}
|
||||
|
||||
{pages > 1 && <div className="flex md:hidden">{next_button}</div>}
|
||||
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-6 w-full md:flex-nowrap justify-center",
|
||||
)}
|
||||
>
|
||||
<Span
|
||||
size="small"
|
||||
variant="faded"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
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 && <div className="hidden md:flex">{next_button}</div>}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
type 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import Modal, { type TWUI_MODAL_PROPS } from "./Modal";
|
||||
|
||||
/**
|
||||
* # Popover Component
|
||||
*/
|
||||
export default function Popover(props: TWUI_MODAL_PROPS) {
|
||||
return <Modal {...props} isPopover />;
|
||||
}
|
||||
@@ -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, type 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Input from "../form/Input";
|
||||
import Button from "../layout/Button";
|
||||
import Row from "../layout/Row";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type DetailedHTMLProps,
|
||||
type ReactNode,
|
||||
} 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?: ComponentProps<typeof Input>;
|
||||
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
componentRef?: React.RefObject<HTMLInputElement | null>;
|
||||
beforeSearchIconComponent?: ReactNode;
|
||||
no_search_button?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # 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,
|
||||
beforeSearchIconComponent,
|
||||
no_search_button,
|
||||
...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);
|
||||
inputProps?.onChange?.(e as any);
|
||||
}}
|
||||
className={twMerge(
|
||||
"rounded-r-none!",
|
||||
"twui-search-input",
|
||||
inputProps?.className,
|
||||
)}
|
||||
wrapperProps={{
|
||||
className: "rounded-r-none!",
|
||||
}}
|
||||
componentRef={inputRef}
|
||||
/>
|
||||
{beforeSearchIconComponent}
|
||||
{no_search_button ? null : (
|
||||
<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={15}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { type DetailedHTMLProps, type 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type LucideProps, Star } from "lucide-react";
|
||||
import React, { type DetailedHTMLProps, type HTMLAttributes } 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { type 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_TABS_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_TABS_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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
ButtonClasses,
|
||||
type TWUIButtonColorType,
|
||||
type TWUIButtonVariantType,
|
||||
} from "../layout/Button";
|
||||
|
||||
export type TWUI_TAG_PROPS = PropsWithChildren &
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
color?: TWUIButtonColorType;
|
||||
variant?: TWUIButtonVariantType;
|
||||
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_TAG_PROPS) {
|
||||
const finalClassName =
|
||||
ButtonClasses[variant || "normal"]?.[color || "primary"] ?? "";
|
||||
|
||||
const mainComponent = (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||
"text-center flex items-center justify-center",
|
||||
finalClassName,
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { type DetailedHTMLProps, type 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:bg-success-dark-fill twui-toast-success"
|
||||
: color == "error"
|
||||
? "bg-error dark:bg-error-dark-fill 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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
circleProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
default_active?: boolean;
|
||||
changeHandler?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-toggle-wrapper
|
||||
* @className_circle twui-toggle-circle
|
||||
*/
|
||||
export default function Toggle({
|
||||
circleProps,
|
||||
default_active,
|
||||
changeHandler,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const [active, setActive] = useState(default_active || false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
changeHandler?.(active);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setReady(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center w-[40px] p-[3px] transition-all",
|
||||
"border border-slate-300 dark:border-white/30 border-solid rounded-full",
|
||||
active ? "justify-end" : "justify-start",
|
||||
"twui-toggle-wrapper",
|
||||
props.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setActive(!active);
|
||||
}}
|
||||
>
|
||||
{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,108 @@
|
||||
import { Send, X } from "lucide-react";
|
||||
import React, { type Dispatch, type SetStateAction } from "react";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import Row from "../../layout/Row";
|
||||
import Button from "../../layout/Button";
|
||||
import CopySlug from "../CopySlug";
|
||||
import AIPromptHistoryModal from "./AIPromptHistoryModal";
|
||||
import AIVoiceTranscription from "./AIVoiceTranscription";
|
||||
import type { VoiceTranscriptionParams } from "./types";
|
||||
|
||||
type Props = VoiceTranscriptionParams & {
|
||||
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>;
|
||||
user_image_url?: string;
|
||||
last_response?: string;
|
||||
};
|
||||
|
||||
export default function AIPromptActionSection({
|
||||
streamRes,
|
||||
setStreamRes,
|
||||
loading,
|
||||
promptFn,
|
||||
history,
|
||||
prompt,
|
||||
setPrompt,
|
||||
currentPromptRef,
|
||||
promptInputRef,
|
||||
user_image_url,
|
||||
transcribing,
|
||||
voice_transcription_fn,
|
||||
last_response,
|
||||
cancel_voice_transcription_fn,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className="w-full justify-between">
|
||||
<Row className="gap-4 px-2">
|
||||
<AIVoiceTranscription
|
||||
transcribing={transcribing}
|
||||
voice_transcription_fn={voice_transcription_fn}
|
||||
cancel_voice_transcription_fn={
|
||||
cancel_voice_transcription_fn
|
||||
}
|
||||
history={history}
|
||||
last_response={last_response}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<AIPromptHistoryModal
|
||||
history={history}
|
||||
user_image_url={user_image_url}
|
||||
transcribing={transcribing}
|
||||
voice_transcription_fn={voice_transcription_fn}
|
||||
cancel_voice_transcription_fn={
|
||||
cancel_voice_transcription_fn
|
||||
}
|
||||
/>
|
||||
</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" }}
|
||||
disabled={transcribing}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import React from "react";
|
||||
import Paper from "../Paper";
|
||||
import Stack from "../../layout/Stack";
|
||||
import AIPromptPreview from "./AIPromptPreview";
|
||||
import Card from "../Card";
|
||||
import Row from "../../layout/Row";
|
||||
import Span from "../../layout/Span";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
import AIPromptBlockInputSection from "./AIPromptBlockInputSection";
|
||||
|
||||
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}
|
||||
/>
|
||||
<AIPromptBlockInputSection
|
||||
{...{
|
||||
model,
|
||||
promptFn,
|
||||
history,
|
||||
loading,
|
||||
mdRes,
|
||||
setMdRes,
|
||||
placeholder,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import React from "react";
|
||||
import Stack from "../../layout/Stack";
|
||||
import LoadingOverlay from "../LoadingOverlay";
|
||||
import Textarea from "../../form/Textarea";
|
||||
import AIPromptActionSection from "./AIPromptActionSection";
|
||||
|
||||
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 AIPromptBlockInputSection({
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import Stack from "../../layout/Stack";
|
||||
import Row from "../../layout/Row";
|
||||
import Card from "../Card";
|
||||
import Border from "../Border";
|
||||
import Img from "../../layout/Img";
|
||||
import type { VoiceTranscriptionParams } from "./types";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import AIVoiceTranscription from "./AIVoiceTranscription";
|
||||
import Span from "../../layout/Span";
|
||||
import CopySlug from "../CopySlug";
|
||||
import EmptyContent from "../EmptyContent";
|
||||
|
||||
type Props = VoiceTranscriptionParams & {
|
||||
history: ChatCompletionMessageParam;
|
||||
user_image_url?: string;
|
||||
};
|
||||
|
||||
export default function AIPromptHistoryAssistantCard({
|
||||
history,
|
||||
user_image_url,
|
||||
cancel_voice_transcription_fn,
|
||||
transcribing,
|
||||
voice_transcription_fn,
|
||||
}: Props) {
|
||||
const text_content = history.content?.toString();
|
||||
|
||||
if (!text_content) {
|
||||
return <EmptyContent title="No Text response content" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row 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>
|
||||
|
||||
<Stack className="grow items-stretch">
|
||||
<Card className="w-full overflow-x-auto xl:p-8">
|
||||
<Stack className="twui-llm-chat-block">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{history.content?.toString()}
|
||||
</ReactMarkdown>
|
||||
</Stack>
|
||||
</Card>{" "}
|
||||
<Row>
|
||||
<AIVoiceTranscription
|
||||
transcribing={transcribing}
|
||||
voice_transcription_fn={voice_transcription_fn}
|
||||
cancel_voice_transcription_fn={
|
||||
cancel_voice_transcription_fn
|
||||
}
|
||||
history={[history]}
|
||||
last_response={history.content?.toString()}
|
||||
/>
|
||||
|
||||
<CopySlug
|
||||
slugText={text_content}
|
||||
justIcon
|
||||
iconProps={{ size: 18, opacity: 0.7 }}
|
||||
title="Copy Content"
|
||||
/>
|
||||
</Row>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import { History } 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 type { VoiceTranscriptionParams } from "./types";
|
||||
import AIPromptHistoryUserCard from "./AIPromptHistoryUserCard";
|
||||
import AIPromptHistoryAssistantCard from "./AIPromptHistoryAssistantCard";
|
||||
|
||||
type Props = VoiceTranscriptionParams & {
|
||||
history: ChatCompletionMessageParam[];
|
||||
user_image_url?: string;
|
||||
};
|
||||
|
||||
export default function AIPromptHistoryModal({
|
||||
history,
|
||||
user_image_url,
|
||||
...props
|
||||
}: Props) {
|
||||
if (!history[0]) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
title="View Chat History"
|
||||
size="smaller"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
className="p-1!"
|
||||
>
|
||||
<History size={20} opacity={0.7} />
|
||||
</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 (
|
||||
<AIPromptHistoryUserCard
|
||||
history={hst}
|
||||
{...props}
|
||||
key={index}
|
||||
user_image_url={user_image_url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIPromptHistoryAssistantCard
|
||||
history={hst}
|
||||
{...props}
|
||||
key={index}
|
||||
user_image_url={user_image_url}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { User } from "lucide-react";
|
||||
import Stack from "../../layout/Stack";
|
||||
import Row from "../../layout/Row";
|
||||
import Card from "../Card";
|
||||
import Border from "../Border";
|
||||
import Img from "../../layout/Img";
|
||||
import type { VoiceTranscriptionParams } from "./types";
|
||||
|
||||
type Props = VoiceTranscriptionParams & {
|
||||
history: ChatCompletionMessageParam;
|
||||
user_image_url?: string;
|
||||
};
|
||||
|
||||
export default function AIPromptHistoryUserCard({
|
||||
history,
|
||||
user_image_url,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className="w-full items-start justify-end flex-nowrap">
|
||||
<Card
|
||||
className={twMerge(
|
||||
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light",
|
||||
"twui-llm-user-prompt-history-block",
|
||||
)}
|
||||
>
|
||||
<Stack className="twui-llm-chat-block">
|
||||
{history.content?.toString()}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{user_image_url ? (
|
||||
<Img
|
||||
alt="Prompting user"
|
||||
size={30}
|
||||
circle
|
||||
src={user_image_url}
|
||||
/>
|
||||
) : (
|
||||
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
|
||||
<User />
|
||||
</Border>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -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 type { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Speech, Voicemail, X } from "lucide-react";
|
||||
import Button from "../../layout/Button";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources";
|
||||
import Row from "../../layout/Row";
|
||||
import Loading from "../Loading";
|
||||
import Border from "../Border";
|
||||
|
||||
type Props = {
|
||||
voice_transcription_fn?: (params: { last_message?: string }) => void;
|
||||
transcribing?: boolean;
|
||||
history?: ChatCompletionMessageParam[];
|
||||
last_response?: string;
|
||||
cancel_voice_transcription_fn?: () => void;
|
||||
};
|
||||
|
||||
export default function AIVoiceTranscription({
|
||||
transcribing,
|
||||
voice_transcription_fn,
|
||||
history,
|
||||
last_response,
|
||||
cancel_voice_transcription_fn,
|
||||
}: Props) {
|
||||
if (!voice_transcription_fn) return null;
|
||||
|
||||
if (transcribing) {
|
||||
return (
|
||||
<Border className="px-1.5! py-0.5!">
|
||||
<Row className="gap-2!">
|
||||
<Loading size="small" />
|
||||
<Button
|
||||
title="Cancel Transcription"
|
||||
className="p-1!"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
cancel_voice_transcription_fn?.();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={20} opacity={0.7} />
|
||||
</Button>
|
||||
</Row>
|
||||
</Border>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
title="Listen to Speech"
|
||||
size="smaller"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
loading={transcribing}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const history_last_response = history?.find(
|
||||
(h) => h.role == "assistant",
|
||||
);
|
||||
voice_transcription_fn({
|
||||
last_message:
|
||||
last_response ||
|
||||
history_last_response?.content?.toString(),
|
||||
});
|
||||
}}
|
||||
className="p-1!"
|
||||
>
|
||||
<Speech size={20} opacity={0.7} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type VoiceTranscriptionParams = {
|
||||
transcribing?: boolean;
|
||||
cancel_voice_transcription_fn?: () => void;
|
||||
voice_transcription_fn?: (params: { last_message?: string }) => void;
|
||||
};
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user