First Commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
type Props = {
|
||||
size?: number;
|
||||
};
|
||||
export default function Logo({ size }: Props) {
|
||||
const sizeRatio = 50 / 100;
|
||||
const width = size || 50;
|
||||
const height = width * sizeRatio;
|
||||
|
||||
return (
|
||||
<a href="/">
|
||||
<img
|
||||
src="/images/logo-white.svg"
|
||||
alt="Main Logo"
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
minWidth: width + "px",
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
|
||||
const MarkdownRenderer2 = ({ content }: { content: string }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const { key, ...cleanProps } = props;
|
||||
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const language = match ? match[1] : "";
|
||||
|
||||
if (!inline && match) {
|
||||
return (
|
||||
<Highlight
|
||||
key={key}
|
||||
theme={themes.vsDark}
|
||||
code={String(children).replace(/\n$/, "")}
|
||||
language={language}
|
||||
>
|
||||
{({
|
||||
className,
|
||||
style,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<pre
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
padding: "1.5rem",
|
||||
borderRadius: "8px",
|
||||
fontSize: "0.9rem",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
{...getLineProps({
|
||||
line,
|
||||
key: i,
|
||||
})}
|
||||
>
|
||||
{line.map((token, key) => (
|
||||
<span
|
||||
key={key}
|
||||
{...getTokenProps({
|
||||
token,
|
||||
key,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for inline code or code without a language tag
|
||||
return (
|
||||
<code key={key} className={className} {...cleanProps}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer2;
|
||||
@@ -0,0 +1,15 @@
|
||||
import sd from "showdown";
|
||||
|
||||
const conv = new sd.Converter({});
|
||||
|
||||
const MarkdownRenderer = ({ content }: { content: string }) => {
|
||||
const html = conv.makeHtml(content);
|
||||
return (
|
||||
<div
|
||||
className="markdown-rendered"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
@@ -0,0 +1,75 @@
|
||||
import _ from "lodash";
|
||||
import React from "react";
|
||||
import { TWUIPopoverStyles } from "../../elements/Modal";
|
||||
import twuiNumberfy from "../../utils/numberfy";
|
||||
|
||||
type Params = {
|
||||
targetElRef: React.RefObject<HTMLElement | null>;
|
||||
position: (typeof TWUIPopoverStyles)[number];
|
||||
};
|
||||
|
||||
export default function twuiGrabPopoverStyles({
|
||||
position,
|
||||
targetElRef,
|
||||
}: Params): React.CSSProperties {
|
||||
if (!targetElRef.current) return {};
|
||||
const rect = targetElRef.current.getBoundingClientRect();
|
||||
|
||||
const targetElCurrStyles = window.getComputedStyle(targetElRef.current);
|
||||
|
||||
const targetElRightPadding = twuiNumberfy(targetElCurrStyles.paddingRight);
|
||||
|
||||
let popoverStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 100,
|
||||
};
|
||||
|
||||
const defaultBottomStyle: React.CSSProperties = {
|
||||
top: rect.bottom + window.scrollY + 8,
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
|
||||
const defaultTopStyleStyle: React.CSSProperties = {
|
||||
bottom: window.innerHeight - (rect.top + window.scrollY) + 8,
|
||||
left: rect.left + window.scrollX + rect.width / 2,
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
|
||||
if (position === "bottom") {
|
||||
popoverStyle = _.merge(popoverStyle, defaultBottomStyle);
|
||||
} else if (position === "bottom-left") {
|
||||
popoverStyle = _.merge(
|
||||
popoverStyle,
|
||||
_.omit(defaultBottomStyle, ["transform"]),
|
||||
{
|
||||
left: rect.left,
|
||||
} as React.CSSProperties
|
||||
);
|
||||
} else if (position === "bottom-right") {
|
||||
popoverStyle = _.merge(
|
||||
popoverStyle,
|
||||
_.omit(defaultBottomStyle, ["left", "transform"]),
|
||||
{
|
||||
right:
|
||||
window.innerWidth -
|
||||
(rect.left + window.scrollX) -
|
||||
rect.width -
|
||||
targetElRightPadding,
|
||||
} as React.CSSProperties
|
||||
);
|
||||
} else if (position === "top") {
|
||||
popoverStyle = _.merge(popoverStyle, defaultTopStyleStyle);
|
||||
} else if (position === "right") {
|
||||
popoverStyle.top = rect.top + window.scrollY + rect.height / 2;
|
||||
popoverStyle.left = rect.right + window.scrollX + 8;
|
||||
popoverStyle.transform = "translateY(-50%)";
|
||||
} else if (position === "left") {
|
||||
popoverStyle.top = rect.top + window.scrollY + rect.height / 2;
|
||||
popoverStyle.right =
|
||||
window.innerWidth - (rect.left + window.scrollX) + 8;
|
||||
popoverStyle.transform = "translateY(-50%)";
|
||||
}
|
||||
|
||||
return popoverStyle;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import ReactDOM from "react-dom";
|
||||
import Button from "../layout/Button";
|
||||
import { X } from "lucide-react";
|
||||
import { TWUI_MODAL_PROPS } from "../elements/Modal";
|
||||
import Paper from "../elements/Paper";
|
||||
import _ from "lodash";
|
||||
|
||||
type Props = TWUI_MODAL_PROPS & {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Modal Main Component
|
||||
*/
|
||||
export default function ModalComponent({ open, setOpen, ...props }: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={twMerge(
|
||||
"fixed z-[200] top-0 left-0 w-screen h-screen",
|
||||
"flex flex-col items-center justify-center p-4",
|
||||
"twui-modal-root"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 bg-dark/80 z-0",
|
||||
"w-screen h-screen"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
}}
|
||||
></div>
|
||||
<Paper
|
||||
{..._.omit(props, ["targetWrapperProps"])}
|
||||
className={twMerge(
|
||||
"z-10 max-w-modal bg-background-light dark:bg-background-dark",
|
||||
"w-full relative max-h-[95vh] overflow-y-auto",
|
||||
"twui-modal-content",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
<Button
|
||||
className="absolute top-0 right-0 p-2"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
title="Close Modal Button"
|
||||
>
|
||||
<X size={30} />
|
||||
</Button>
|
||||
</Paper>
|
||||
</div>,
|
||||
document.getElementById("twui-modal-root") as HTMLElement
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import ReactDOM from "react-dom";
|
||||
import { TWUI_MODAL_PROPS } from "../elements/Modal";
|
||||
import Paper from "../elements/Paper";
|
||||
import _ from "lodash";
|
||||
import twuiGrabPopoverStyles from "../(functions)/popver/grab-popover-styles";
|
||||
|
||||
type Props = TWUI_MODAL_PROPS & {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
targetElRef?: React.RefObject<HTMLElement | null>;
|
||||
popoverTargetActiveRef: React.MutableRefObject<boolean>;
|
||||
popoverContentActiveRef: React.MutableRefObject<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Modal Main Component
|
||||
*/
|
||||
export default function PopoverComponent({
|
||||
open,
|
||||
setOpen,
|
||||
targetElRef,
|
||||
position = "bottom",
|
||||
trigger = "hover",
|
||||
debounce,
|
||||
popoverTargetActiveRef,
|
||||
popoverContentActiveRef,
|
||||
popoverReferenceRef,
|
||||
isPopover,
|
||||
...props
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const [style, setStyle] = React.useState({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && targetElRef?.current) {
|
||||
const popoverStyle = twuiGrabPopoverStyles({
|
||||
position,
|
||||
targetElRef,
|
||||
});
|
||||
setStyle(popoverStyle);
|
||||
}
|
||||
}, [open, targetElRef, position]);
|
||||
|
||||
let closeTimeout: any;
|
||||
|
||||
const popoverEnterFn = React.useCallback(() => {
|
||||
popoverContentActiveRef.current = true;
|
||||
popoverTargetActiveRef.current = false;
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const popoverLeaveFn = React.useCallback(() => {
|
||||
window.clearTimeout(closeTimeout);
|
||||
closeTimeout = setTimeout(() => {
|
||||
if (popoverTargetActiveRef.current) {
|
||||
popoverTargetActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<Paper
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"max-w-[300px] z-[250]",
|
||||
"twui-popover-content",
|
||||
props.className
|
||||
)}
|
||||
style={{ ...style, ...props.style }}
|
||||
onMouseEnter={
|
||||
trigger === "hover" ? popoverEnterFn : props.onMouseEnter
|
||||
}
|
||||
onMouseLeave={
|
||||
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* <div
|
||||
className="absolute w-0 h-0 border-8 border-transparent bg-white"
|
||||
style={{
|
||||
...(position === "bottom" && {
|
||||
top: "-16px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}),
|
||||
...(position === "top" && {
|
||||
bottom: "-16px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}),
|
||||
...(position === "right" && {
|
||||
top: "50%",
|
||||
left: "-16px",
|
||||
transform: "translateY(-50%)",
|
||||
}),
|
||||
...(position === "left" && {
|
||||
top: "50%",
|
||||
right: "-16px",
|
||||
transform: "translateY(-50%)",
|
||||
}),
|
||||
}}
|
||||
/> */}
|
||||
{props.children}
|
||||
</Paper>,
|
||||
document.getElementById("twui-popover-root") as HTMLElement
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Tailwind CSS UI
|
||||
|
||||
A modular skeletal framework for tailwind css
|
||||
|
||||
## Perequisites
|
||||
|
||||
You need a couple of packages and settings to integrate this package
|
||||
|
||||
### Packages
|
||||
|
||||
- React
|
||||
- React Dom
|
||||
- Tailwind CSS **version 4**
|
||||
|
||||
### CSS Base
|
||||
|
||||
This package contains a `base.css` file which has all the base css rules required to run. This css file must be imported in your base project, and it can be update in a separate `.css` file.
|
||||
|
||||
### Install packages
|
||||
|
||||
```sh
|
||||
bun add lucide-react tailwind-merge html-to-react gray-matter mdx typescript lodash react-code-blocks react-responsive-modal next-mdx-remote remark-gfm rehype-prism-plus openai
|
||||
```
|
||||
|
||||
```sh
|
||||
bun add -D @types/ace @types/react @types/react-dom tailwindcss @types/mdx @next/mdx
|
||||
```
|
||||
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--breakpoint-xs: 350px;
|
||||
--breakpoint-xxs: 300px;
|
||||
--breakpoint-xxl: 1600px;
|
||||
|
||||
--color-background-light: #ffffff;
|
||||
--color-foreground-light: #171717;
|
||||
--color-background-dark: #0a0a0a;
|
||||
--color-foreground-dark: #ededed;
|
||||
|
||||
--color-dark: #000000;
|
||||
|
||||
--color-primary: #000000;
|
||||
--color-primary-hover: #29292b;
|
||||
--color-primary-outline: #29292b;
|
||||
--color-primary-text: #29292b;
|
||||
--color-primary-dark: #29292b;
|
||||
--color-primary-dark-hover: #4b4b4b;
|
||||
--color-primary-dark-outline: #4b4b4b;
|
||||
--color-primary-dark-text: #ffffff;
|
||||
|
||||
--color-secondary: #000000;
|
||||
--color-secondary-hover: #dddddd;
|
||||
--color-secondary-outline: #dddddd;
|
||||
--color-secondary-text: #dddddd;
|
||||
--color-secondary-dark: #000000;
|
||||
--color-secondary-dark-hover: #dddddd;
|
||||
--color-secondary-dark-outline: #dddddd;
|
||||
--color-secondary-dark-text: #dddddd;
|
||||
|
||||
--color-accent: #000000;
|
||||
--color-accent-hover: #dddddd;
|
||||
--color-accent-outline: #dddddd;
|
||||
--color-accent-text: #dddddd;
|
||||
--color-accent-dark: #000000;
|
||||
--color-accent-dark-hover: #dddddd;
|
||||
--color-accent-dark-outline: #dddddd;
|
||||
--color-accent-dark-text: #dddddd;
|
||||
|
||||
--color-gray: #dfe6ef;
|
||||
--color-gray-hover: #dfe6ef;
|
||||
--color-gray-dark: #1d2b3f;
|
||||
--color-gray-dark-hover: #132033;
|
||||
|
||||
--color-success: #0aa156;
|
||||
--color-success-dark: #0aa156;
|
||||
|
||||
--color-error: #e5484d;
|
||||
--color-error-dark: #e5484d;
|
||||
|
||||
--color-warning: #ff6900;
|
||||
|
||||
--color-link: #0051c9;
|
||||
--color-link-dark: #548adb;
|
||||
|
||||
--radius-default: 5px;
|
||||
--radius-default-sm: 3px;
|
||||
--radius-default-xs: 1px;
|
||||
--radius-default-lg: 7px;
|
||||
--radius-default-xl: 10px;
|
||||
|
||||
--container-container: 1200px;
|
||||
--container-modal: 800px;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
body {
|
||||
@apply bg-background-light dark:bg-background-dark;
|
||||
@apply text-foreground-light dark:text-foreground-dark;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.tox-tinymce {
|
||||
@apply w-full !rounded-default !border-slate-300 dark:!border-white/20;
|
||||
}
|
||||
|
||||
/* .moving-object {
|
||||
@apply !bg-green-500;
|
||||
} */
|
||||
|
||||
option {
|
||||
@apply dark:bg-background-dark;
|
||||
}
|
||||
|
||||
.mobile-paper-hidden {
|
||||
@apply max-md:p-0 max-md:border-none max-md:bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray rounded-full dark:bg-gray;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-foreground-light/40 rounded-full hover:bg-foreground-light/60;
|
||||
@apply dark:bg-foreground-dark/40 rounded-full hover:bg-foreground-dark/60;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
|
||||
}
|
||||
|
||||
@supports (selector(:where(*))) {
|
||||
:where(*) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
|
||||
}
|
||||
|
||||
.dark :where(*) {
|
||||
scrollbar-color: theme("colors.gray.500") theme("colors.gray.800");
|
||||
}
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
@apply dark:bg-background-dark;
|
||||
}
|
||||
|
||||
.tox-editor-header,
|
||||
.tox-toolbar-overlord,
|
||||
.tox .tox-toolbar,
|
||||
.tox .tox-toolbar__overflow,
|
||||
.tox .tox-toolbar__primary,
|
||||
.tox .tox-tbtn,
|
||||
.tox .tox-sidebar,
|
||||
.tox .tox-statusbar,
|
||||
.tox .tox-view-wrap,
|
||||
.tox .tox-view-wrap__slot-container,
|
||||
.tox .tox-editor-container,
|
||||
.tox .tox-edit-area__iframe,
|
||||
.twui-tinymce {
|
||||
@apply dark:!bg-background-dark;
|
||||
}
|
||||
|
||||
.twui-tinymce *:focus {
|
||||
@apply !outline-white/10;
|
||||
}
|
||||
|
||||
.tox .tox-tbtn:hover {
|
||||
@apply dark:!bg-white/10;
|
||||
}
|
||||
|
||||
.ace_gutter {
|
||||
@apply dark:!bg-background-dark;
|
||||
}
|
||||
|
||||
.ace_active-line,
|
||||
.ace_gutter-active-line {
|
||||
@apply dark:!bg-white/5;
|
||||
}
|
||||
|
||||
.normal-text {
|
||||
@apply text-foreground-light dark:text-foreground-dark;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: 25px;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tailwind-ui",
|
||||
"dependencies": {
|
||||
"@xterm/xterm": "latest",
|
||||
"html-to-react": "^1.7.0",
|
||||
"lodash": "latest",
|
||||
"lucide-react": "latest",
|
||||
"react-code-blocks": "latest",
|
||||
"react-responsive-modal": "latest",
|
||||
"tailwind-merge": "latest",
|
||||
"typescript": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/mdx": "latest",
|
||||
"@types/ace": "latest",
|
||||
"@types/bun": "latest",
|
||||
"@types/lodash": "latest",
|
||||
"@types/mdx": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "^4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
|
||||
|
||||
"@bedrock-layout/use-forwarded-ref": ["@bedrock-layout/[email protected]", "", { "dependencies": { "@bedrock-layout/use-stateful-ref": "^1.4.1" }, "peerDependencies": { "react": "^16.8 || ^17 || ^18" } }, "sha512-GD9A9AFLzFNjr7k6fgerSqxfwDWl+wsPS11PErOKe1zkVz0y7RGC9gzlOiX/JrgpyB3NFHWIuGtoOQqifJQQpw=="],
|
||||
|
||||
"@bedrock-layout/use-stateful-ref": ["@bedrock-layout/[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18" } }, "sha512-4eKO2KdQEXcR5LI4QcxqlJykJUDQJWDeWYAukIn6sRQYoabcfI5kDl61PUi6FR6o8VFgQ8IEP7HleKqWlSe8SQ=="],
|
||||
|
||||
"@emotion/is-prop-valid": ["@emotion/[email protected]", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="],
|
||||
|
||||
"@emotion/memoize": ["@emotion/[email protected]", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="],
|
||||
|
||||
"@emotion/unitless": ["@emotion/[email protected]", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="],
|
||||
|
||||
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-D6lSSbVzn1EiPwrBKG5QzXClcgdqiNCL8a3/6oROinzgZnYSxbVmnfs0UrqygtGSOmgW7sdJJSEOy555DoAwvw=="],
|
||||
|
||||
"@types/ace": ["@types/[email protected]", "", {}, "sha512-YPF9S7fzpuyrxru+sG/rrTpZkC6gpHBPF14W3x70kqVOD+ks6jkYLapk4yceh36xej7K4HYxcyz9ZDQ2lTvwgQ=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
||||
|
||||
"@types/hast": ["@types/[email protected]", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"@types/lodash": ["@types/[email protected]", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
|
||||
|
||||
"@types/mdx": ["@types/[email protected]", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
|
||||
|
||||
"@types/stylis": ["@types/[email protected]", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="],
|
||||
|
||||
"@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@xterm/xterm": ["@xterm/[email protected]", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
|
||||
|
||||
"body-scroll-lock": ["[email protected]", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
|
||||
|
||||
"camelize": ["[email protected]", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
|
||||
|
||||
"character-entities": ["[email protected]", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
|
||||
|
||||
"character-entities-legacy": ["[email protected]", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
|
||||
|
||||
"character-reference-invalid": ["[email protected]", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
|
||||
|
||||
"classnames": ["[email protected]", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
|
||||
|
||||
"css-color-keywords": ["[email protected]", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
|
||||
|
||||
"css-to-react-native": ["[email protected]", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"fault": ["[email protected]", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
|
||||
|
||||
"format": ["[email protected]", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||
|
||||
"hast-util-parse-selector": ["[email protected]", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
|
||||
|
||||
"hastscript": ["[email protected]", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
|
||||
|
||||
"highlight.js": ["[email protected]", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"highlightjs-vue": ["[email protected]", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||
|
||||
"html-to-react": ["[email protected]", "", { "dependencies": { "domhandler": "^5.0", "htmlparser2": "^9.0", "lodash.camelcase": "^4.3.0" }, "peerDependencies": { "react": "^0.13.0 || ^0.14.0 || >=15" } }, "sha512-b5HTNaTGyOj5GGIMiWVr1k57egAZ/vGy0GGefnCQ1VW5hu9+eku8AXHtf2/DeD95cj/FKBKYa1J7SWBOX41yUQ=="],
|
||||
|
||||
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||
|
||||
"is-alphabetical": ["[email protected]", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
|
||||
|
||||
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
|
||||
|
||||
"is-decimal": ["[email protected]", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
|
||||
|
||||
"is-hexadecimal": ["[email protected]", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.camelcase": ["[email protected]", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lowlight": ["[email protected]", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
|
||||
|
||||
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="],
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"parse-entities": ["[email protected]", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
|
||||
|
||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"prismjs": ["[email protected]", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
"property-information": ["[email protected]", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"react-code-blocks": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.10.4", "react-syntax-highlighter": "^15.5.0", "styled-components": "^6.1.0", "tslib": "^2.6.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-ENNuxG07yO+OuX1ChRje3ieefPRz6yrIpHmebQlaFQgzcAHbUfVeTINpOpoI9bSRSObeYo/OdHsporeToZ7fcg=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-responsive-modal": ["[email protected]", "", { "dependencies": { "@bedrock-layout/use-forwarded-ref": "^1.3.1", "body-scroll-lock": "^3.1.5", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-ARjGEKE5Gu5CSvyA8U9ARVbtK4SMAtdXsjtzwtxRlQIHC99RQTnOUctLpl7+/sp1Kg1OJZ6yqvp6ivd4TBueEw=="],
|
||||
|
||||
"react-syntax-highlighter": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.27.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg=="],
|
||||
|
||||
"refractor": ["[email protected]", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"shallowequal": ["[email protected]", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
|
||||
|
||||
"source-map": ["[email protected]", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["[email protected]", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
||||
|
||||
"styled-components": ["[email protected]", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw=="],
|
||||
|
||||
"stylis": ["[email protected]", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="],
|
||||
|
||||
"tailwind-merge": ["[email protected]", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"xtend": ["[email protected]", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"refractor/prismjs": ["[email protected]", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||
|
||||
"styled-components/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||
|
||||
"styled-components/tslib": ["[email protected]", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { DocsLinkType } from ".";
|
||||
import Stack from "../../layout/Stack";
|
||||
import TWUIDocsLink from "./TWUIDocsLink";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
|
||||
DocsLinks: DocsLinkType[];
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
autoExpandAll?: boolean;
|
||||
};
|
||||
export default function TWUIDocsAside({
|
||||
DocsLinks,
|
||||
after,
|
||||
before,
|
||||
autoExpandAll,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<aside
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"pb-10 hidden xl:flex sticky top-6",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
{before}
|
||||
{DocsLinks.map((link, index) => (
|
||||
<TWUIDocsLink
|
||||
docLink={link}
|
||||
key={index}
|
||||
autoExpandAll={autoExpandAll}
|
||||
/>
|
||||
))}
|
||||
{after}
|
||||
</Stack>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, {
|
||||
AnchorHTMLAttributes,
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
} from "react";
|
||||
import { DocsLinkType } from ".";
|
||||
import Stack from "../../layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../../layout/Row";
|
||||
import Divider from "../../layout/Divider";
|
||||
import { ChevronDown, Circle } from "lucide-react";
|
||||
import Button from "../../layout/Button";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
> & {
|
||||
docLink: DocsLinkType;
|
||||
wrapperProps?: ComponentProps<typeof Stack>;
|
||||
strict?: boolean;
|
||||
childWrapperProps?: ComponentProps<typeof Stack>;
|
||||
autoExpandAll?: boolean;
|
||||
child?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # TWUI Docs Left Aside Link
|
||||
* @note use dataset attribute `data-strict` for strict matching
|
||||
*
|
||||
* @className `twui-docs-left-aside-link`
|
||||
*/
|
||||
export default function TWUIDocsLink({
|
||||
docLink,
|
||||
wrapperProps,
|
||||
childWrapperProps,
|
||||
strict,
|
||||
autoExpandAll,
|
||||
child,
|
||||
...props
|
||||
}: Props) {
|
||||
const [isActive, setIsActive] = React.useState(false);
|
||||
const [expand, setExpand] = React.useState(autoExpandAll || false);
|
||||
const linkRef = React.useRef<HTMLAnchorElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const basePathMatch = window.location.pathname.includes(
|
||||
docLink.href
|
||||
);
|
||||
|
||||
const isStrictMatch = Boolean(
|
||||
linkRef.current?.getAttribute("data-strict")
|
||||
);
|
||||
|
||||
if (strict || isStrictMatch) {
|
||||
setIsActive(window.location.pathname === docLink.href);
|
||||
} else {
|
||||
setIsActive(basePathMatch);
|
||||
}
|
||||
|
||||
if (basePathMatch) {
|
||||
setExpand(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={twMerge("gap-2 w-full", wrapperProps?.className)}
|
||||
{...wrapperProps}
|
||||
>
|
||||
<Row className="flex-nowrap grow justify-between w-full">
|
||||
{child && <Circle size={6} />}
|
||||
<a
|
||||
href={docLink.href}
|
||||
title={docLink.title}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"twui-docs-left-aside-link whitespace-nowrap",
|
||||
"grow overflow-hidden overflow-ellipsis",
|
||||
isActive ? "active" : "",
|
||||
props.className
|
||||
)}
|
||||
ref={linkRef}
|
||||
data-strict={strict || docLink.strict}
|
||||
>
|
||||
{docLink.title}
|
||||
</a>
|
||||
|
||||
{docLink.children?.[0] && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
className={twMerge(
|
||||
"p-1 hover:opacity-100",
|
||||
expand ? "rotate-180 opacity-30" : "opacity-70"
|
||||
)}
|
||||
onClick={() => setExpand(!expand)}
|
||||
title="Docs Aside Links Dropdown Button"
|
||||
>
|
||||
<ChevronDown className="text-slate-500" size={20} />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
{docLink.children && expand && (
|
||||
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"gap-2 w-full pl-3",
|
||||
childWrapperProps?.className
|
||||
)}
|
||||
{...childWrapperProps}
|
||||
>
|
||||
{docLink.children.map((link, index) => (
|
||||
<TWUIDocsLink
|
||||
key={index}
|
||||
docLink={link}
|
||||
className="opacity-70"
|
||||
autoExpandAll={autoExpandAll}
|
||||
child
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Row>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { DocsLinkType } from ".";
|
||||
import Stack from "../../layout/Stack";
|
||||
import TWUIDocsLink from "./TWUIDocsLink";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Span from "../../layout/Span";
|
||||
import Row from "../../layout/Row";
|
||||
import { ArrowUpRight, LinkIcon, ListIcon } from "lucide-react";
|
||||
import Link from "../../layout/Link";
|
||||
|
||||
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
|
||||
before?: React.ReactNode;
|
||||
after?: React.ReactNode;
|
||||
autoExpandAll?: boolean;
|
||||
editPageURL?: string;
|
||||
};
|
||||
export default function TWUIDocsRightAside({
|
||||
after,
|
||||
before,
|
||||
autoExpandAll,
|
||||
editPageURL,
|
||||
...props
|
||||
}: Props) {
|
||||
const [links, setLinks] = React.useState<DocsLinkType[]>([]);
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
|
||||
const headerHrefs = document.querySelectorAll(
|
||||
".twui-docs-header-anchor"
|
||||
);
|
||||
|
||||
const linksArr: DocsLinkType[] = [];
|
||||
|
||||
for (let i = 0; i < headerHrefs.length; i++) {
|
||||
const anchorEl = headerHrefs[i] as HTMLAnchorElement;
|
||||
|
||||
const isH2Element = anchorEl.querySelector("h2") !== null;
|
||||
|
||||
if (isH2Element) {
|
||||
let newLink: DocsLinkType = {
|
||||
title: anchorEl.textContent || "",
|
||||
href: `#${anchorEl.id}`,
|
||||
};
|
||||
|
||||
let nexElIndex = i + 1;
|
||||
|
||||
while (nexElIndex < headerHrefs.length) {
|
||||
const nextElement = headerHrefs[
|
||||
nexElIndex
|
||||
] as HTMLAnchorElement;
|
||||
|
||||
const nextElementH3 = nextElement.querySelector("h3");
|
||||
|
||||
const isNextElementH2 =
|
||||
nextElement.querySelector("h2") !== null;
|
||||
|
||||
if (isNextElementH2) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!nextElementH3) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!newLink.children) {
|
||||
newLink.children = [];
|
||||
}
|
||||
|
||||
newLink.children.push({
|
||||
title: nextElementH3.textContent || "",
|
||||
href: `#${nextElement.id}`,
|
||||
});
|
||||
|
||||
nexElIndex++;
|
||||
}
|
||||
|
||||
linksArr.push(newLink);
|
||||
}
|
||||
}
|
||||
|
||||
setLinks(linksArr);
|
||||
}, [ready]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!links.length) return;
|
||||
|
||||
const headerHrefs = document.querySelectorAll(
|
||||
"a.twui-docs-header-anchor"
|
||||
);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id;
|
||||
|
||||
const link = document.querySelector(
|
||||
`.twui-docs-right-aside a[href="#${id}"]`
|
||||
);
|
||||
if (link) {
|
||||
link.classList.add("active");
|
||||
}
|
||||
} else {
|
||||
const id = entry.target.id;
|
||||
const link = document.querySelector(
|
||||
`.twui-docs-right-aside a[href="#${id}"]`
|
||||
);
|
||||
if (link) {
|
||||
link.classList.remove("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
headerHrefs.forEach((headerHref) => {
|
||||
observer.observe(headerHref);
|
||||
});
|
||||
}, [links]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setReady(true);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"pb-10 hidden xl:flex min-w-[150px] max-w-[200px]",
|
||||
"sticky top-6 twui-docs-right-aside",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full overflow-hidden">
|
||||
{before}
|
||||
<Stack className="w-full">
|
||||
<Row>
|
||||
<ListIcon size={12} opacity={0.5} />
|
||||
<Span size="smaller" variant="faded">
|
||||
On this page
|
||||
</Span>
|
||||
</Row>
|
||||
{links.map((link, index) => (
|
||||
<TWUIDocsLink
|
||||
docLink={link}
|
||||
key={index}
|
||||
autoExpandAll
|
||||
childWrapperProps={{
|
||||
className: "pl-2",
|
||||
}}
|
||||
wrapperProps={{
|
||||
className:
|
||||
"[&_svg]:hidden [&_a]:text-xs [&_a]:overflow-hidden [&_a]:overflow-ellipsis",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{after}
|
||||
{editPageURL && (
|
||||
<Link
|
||||
href={editPageURL}
|
||||
target="_blank"
|
||||
className="text-[12px] mt-2"
|
||||
>
|
||||
<Row className="gap-2">
|
||||
<LinkIcon size={12} opacity={0.5} />
|
||||
<span>Edit This Page</span>
|
||||
<ArrowUpRight size={15} className="-ml-1" />
|
||||
</Row>
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
import Stack from "../../layout/Stack";
|
||||
import Container from "../../layout/Container";
|
||||
import Row from "../../layout/Row";
|
||||
import TWUIDocsAside from "./TWUIDocsAside";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Paper from "../../elements/Paper";
|
||||
import TWUIDocsRightAside from "./TWUIDocsRightAside";
|
||||
|
||||
export type DocsLinkType = {
|
||||
title: string;
|
||||
href: string;
|
||||
strict?: boolean;
|
||||
children?: DocsLinkType[];
|
||||
editPage?: string;
|
||||
};
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
DocsLinks: DocsLinkType[];
|
||||
docsAsideBefore?: React.ReactNode;
|
||||
docsAsideAfter?: React.ReactNode;
|
||||
wrapperProps?: ComponentProps<typeof Stack>;
|
||||
docsContentProps?: ComponentProps<typeof Row>;
|
||||
leftAsideProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>;
|
||||
autoExpandAll?: boolean;
|
||||
editPageURL?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # TWUI Docs
|
||||
* @className `twui-docs-content`
|
||||
*/
|
||||
export default function TWUIDocs({
|
||||
children,
|
||||
DocsLinks,
|
||||
docsAsideAfter,
|
||||
docsAsideBefore,
|
||||
wrapperProps,
|
||||
docsContentProps,
|
||||
leftAsideProps,
|
||||
autoExpandAll,
|
||||
editPageURL,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack
|
||||
center
|
||||
{...wrapperProps}
|
||||
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
|
||||
>
|
||||
<Container>
|
||||
<Paper className="xl:p-8 mobile-paper-hidden">
|
||||
<Row
|
||||
{...docsContentProps}
|
||||
className={twMerge(
|
||||
"items-start gap-8 w-full flex-nowrap",
|
||||
docsContentProps?.className
|
||||
)}
|
||||
>
|
||||
<TWUIDocsAside
|
||||
DocsLinks={DocsLinks}
|
||||
after={docsAsideAfter}
|
||||
before={docsAsideBefore}
|
||||
autoExpandAll={autoExpandAll}
|
||||
{...leftAsideProps}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"block twui-docs-content pl-0 xl:pl-6 grow",
|
||||
"overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<TWUIDocsRightAside editPageURL={editPageURL} />
|
||||
</Row>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { MutableRefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AceEditorModes from "./ace-editor-modes";
|
||||
import { AceEditorOptions } from "@moduletrace/datasquirel/dist/package-shared/types";
|
||||
|
||||
export type AceEditorComponentType = {
|
||||
editorRef?: MutableRefObject<AceAjax.Editor | undefined>;
|
||||
readOnly?: boolean;
|
||||
/** Function to call when Ctrl+Enter is pressed */
|
||||
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
|
||||
content?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
mode?: (typeof AceEditorModes)[number];
|
||||
fontSize?: string;
|
||||
previewMode?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
delay?: number;
|
||||
wrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
refreshDepArr?: any[];
|
||||
editorOptions?: AceEditorOptions;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Powerful Ace Editor
|
||||
* @note **NOTE** head scripts required
|
||||
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js`
|
||||
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js`
|
||||
*/
|
||||
export default function AceEditor({
|
||||
editorRef,
|
||||
readOnly,
|
||||
ctrlEnterFn,
|
||||
content = "",
|
||||
placeholder,
|
||||
mode,
|
||||
fontSize,
|
||||
previewMode,
|
||||
onChange,
|
||||
delay = 500,
|
||||
refreshDepArr,
|
||||
wrapperProps,
|
||||
editorOptions,
|
||||
showLabel,
|
||||
title,
|
||||
}: AceEditorComponentType) {
|
||||
try {
|
||||
const editorElementRef = React.useRef<HTMLDivElement>();
|
||||
const editorRefInstance = React.useRef<AceAjax.Editor>();
|
||||
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
|
||||
if (!ace?.edit || !editorElementRef.current) {
|
||||
setTimeout(() => {
|
||||
setRefresh((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = ace.edit(editorElementRef.current);
|
||||
|
||||
editor.setOptions({
|
||||
mode: `ace/mode/${mode ? mode : "javascript"}`,
|
||||
theme: darkMode
|
||||
? "ace/theme/tomorrow_night_eighties"
|
||||
: "ace/theme/ace_light",
|
||||
value: (() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 4);
|
||||
} catch (error) {
|
||||
return content;
|
||||
}
|
||||
})(),
|
||||
placeholder: placeholder ? placeholder : "",
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
readOnly: readOnly ? true : false,
|
||||
fontSize: fontSize ? fontSize : null,
|
||||
showLineNumbers: previewMode ? false : true,
|
||||
wrap: true,
|
||||
wrapMethod: "code",
|
||||
...editorOptions,
|
||||
});
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: "myCommand",
|
||||
bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" },
|
||||
exec: function (editor) {
|
||||
if (ctrlEnterFn) ctrlEnterFn(editor);
|
||||
},
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
editor.getSession().on("change", function (e) {
|
||||
if (onChange) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
onChange(editor.getValue());
|
||||
} catch (error) {}
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
|
||||
editorRefInstance.current = editor;
|
||||
if (editorRef) editorRef.current = editor;
|
||||
|
||||
return function () {
|
||||
editor.destroy();
|
||||
};
|
||||
}, [refresh, darkMode, ready, mode, ...(refreshDepArr || [])]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlClassName = document.documentElement.className;
|
||||
if (htmlClassName.match(/dark/i)) setDarkMode(true);
|
||||
setTimeout(() => {
|
||||
setReady(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"w-full h-[400px] block rounded-default",
|
||||
"border border-slate-200 border-solid relative",
|
||||
"dark:border-white/20",
|
||||
showLabel && title ? "pt-4" : "",
|
||||
wrapperProps?.className,
|
||||
)}
|
||||
>
|
||||
{showLabel && title ? (
|
||||
<label
|
||||
className={twMerge(
|
||||
"bg-background-light dark:bg-background-dark text-xs",
|
||||
"-top-3 left-2 px-2 py-1 absolute z-10",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
ref={editorElementRef as any}
|
||||
className="w-full h-full"
|
||||
></div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} catch (error: any) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<span className="m-0">
|
||||
Editor Error:{" "}
|
||||
<b className="text-red-600">{error.message}</b>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { ComponentProps, MutableRefObject } from "react";
|
||||
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||
import Border from "../../elements/Border";
|
||||
import useTinyMCE from "./useTinyMCE";
|
||||
|
||||
export type TinyMCEEditorProps<KeyType extends string> = {
|
||||
options?: RawEditorOptions;
|
||||
editorRef?: React.MutableRefObject<Editor | null>;
|
||||
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
|
||||
wrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
wrapperWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
borderProps?: ComponentProps<typeof Border>;
|
||||
defaultValue?: string;
|
||||
name?: KeyType;
|
||||
changeHandler?: (content: string) => void;
|
||||
showLabel?: boolean;
|
||||
useParentCSS?: boolean;
|
||||
placeholder?: string;
|
||||
refreshDependencyArray?: any[];
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tiny MCE Editor Component
|
||||
* @className_wrapper twui-rte-wrapper
|
||||
*/
|
||||
export default function TinyMCEEditor<KeyType extends string>({
|
||||
options,
|
||||
editorRef: passedEditorRef,
|
||||
setEditor: passedSetEditor,
|
||||
wrapperProps,
|
||||
defaultValue,
|
||||
changeHandler,
|
||||
wrapperWrapperProps,
|
||||
borderProps,
|
||||
name,
|
||||
showLabel,
|
||||
useParentCSS,
|
||||
placeholder,
|
||||
refreshDependencyArray,
|
||||
}: TinyMCEEditorProps<KeyType>) {
|
||||
const { tinyMCE } = useTinyMCE();
|
||||
|
||||
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
||||
const editorRef: MutableRefObject<Editor | null> =
|
||||
passedEditorRef || React.useRef(null);
|
||||
|
||||
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
|
||||
|
||||
const FINAL_HEIGHT = options?.height || 500;
|
||||
const [themeReady, setThemeReady] = React.useState(false);
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [editor, setEditor] = React.useState<Editor>();
|
||||
|
||||
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!tinyMCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlClassName = document.documentElement.className;
|
||||
|
||||
if (htmlClassName.match(/dark/i)) setDarkMode(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setThemeReady(true);
|
||||
}, 200);
|
||||
}, [tinyMCE]);
|
||||
|
||||
let valueTimeout: any;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editorComponentRef.current || !themeReady || !tinyMCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = "https://www.datasquirel.com/tinymce-public";
|
||||
|
||||
tinyMCE.init({
|
||||
height: FINAL_HEIGHT,
|
||||
menubar: false,
|
||||
plugins:
|
||||
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
|
||||
toolbar:
|
||||
"undo redo | blocks | bold italic underline link image | bullist numlist outdent indent | removeformat code searchreplace wordcount preview insertdatetime",
|
||||
content_style:
|
||||
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
|
||||
init_instance_callback: (editor) => {
|
||||
setEditor(editor as any);
|
||||
if (editorRef) {
|
||||
editorRef.current = editor;
|
||||
passedSetEditor?.(editor);
|
||||
}
|
||||
if (defaultValue) editor.setContent(defaultValue);
|
||||
setReady(true);
|
||||
|
||||
// editor.on("change", (e) => {
|
||||
// changeHandler?.(editor.getContent());
|
||||
// });
|
||||
|
||||
editor.on("input", (e) => {
|
||||
if (changeHandler) {
|
||||
window.clearTimeout(valueTimeout);
|
||||
|
||||
valueTimeout = setTimeout(() => {
|
||||
changeHandler(editor.getContent());
|
||||
}, EDITOR_VALUE_CHANGE_TIMEOUT);
|
||||
}
|
||||
});
|
||||
|
||||
if (useParentCSS) {
|
||||
useParentStyles(editor);
|
||||
}
|
||||
},
|
||||
base_url: baseUrl,
|
||||
body_class: "twui-tinymce",
|
||||
placeholder,
|
||||
relative_urls: true,
|
||||
remove_script_host: true,
|
||||
convert_urls: false,
|
||||
...options,
|
||||
license_key: "gpl",
|
||||
target: editorComponentRef.current,
|
||||
content_css: darkMode ? "dark" : undefined,
|
||||
skin: darkMode ? "oxide-dark" : undefined,
|
||||
});
|
||||
|
||||
return function () {
|
||||
if (!ready) return;
|
||||
|
||||
const instance = editorComponentRef.current
|
||||
? tinyMCE?.get(editorComponentRef.current?.id)
|
||||
: undefined;
|
||||
instance?.remove();
|
||||
};
|
||||
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const instance = editorRef.current;
|
||||
|
||||
if (instance) {
|
||||
instance.setContent(defaultValue || "");
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge(
|
||||
"relative w-full [&_.tox-tinymce]:!border-none",
|
||||
"bg-background-light dark:bg-background-dark",
|
||||
wrapperWrapperProps?.className,
|
||||
)}
|
||||
onInput={(e) => {
|
||||
console.log(`Input Detected`);
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<label
|
||||
className={twMerge(
|
||||
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
|
||||
"bg-background-light dark:bg-background-dark text-gray-500",
|
||||
"dark:text-white/80 rounded",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{title}
|
||||
</label>
|
||||
)}
|
||||
<Border
|
||||
{...borderProps}
|
||||
className={twMerge(
|
||||
"dark:border-white/30 p-0 pt-2",
|
||||
borderProps?.className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
ref={editorComponentRef}
|
||||
style={{
|
||||
height:
|
||||
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
|
||||
...wrapperProps?.style,
|
||||
}}
|
||||
className={twMerge(
|
||||
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
|
||||
"twui-rte-wrapper",
|
||||
)}
|
||||
id={id}
|
||||
></div>
|
||||
</Border>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useParentStyles(editor: Editor) {
|
||||
const doc = editor.getDoc();
|
||||
const parentStylesheets = document.styleSheets;
|
||||
|
||||
for (const sheet of parentStylesheets) {
|
||||
try {
|
||||
if (sheet.href) {
|
||||
const link = doc.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = sheet.href;
|
||||
doc.head.appendChild(link);
|
||||
} else {
|
||||
const rules = sheet.cssRules || sheet.rules;
|
||||
if (rules) {
|
||||
const style = doc.createElement("style");
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
style.appendChild(doc.createTextNode(rule.cssText));
|
||||
} catch (e) {
|
||||
console.warn("Could not copy CSS rule:", rule, e);
|
||||
}
|
||||
}
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error processing stylesheet:", sheet, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { TinyMCE } from "./tinymce";
|
||||
|
||||
let interval: any;
|
||||
|
||||
export default function useTinyMCE() {
|
||||
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE>();
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [scriptLoaded, setScriptLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (refresh >= 5) return;
|
||||
|
||||
const clientWindow = window as Window & { tinymce?: TinyMCE };
|
||||
|
||||
if (clientWindow.tinymce) {
|
||||
setScriptLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
|
||||
const baseUrl = "https://www.datasquirel.com/tinymce-public";
|
||||
|
||||
script.src = `${baseUrl}/tinymce.min.js`;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
setScriptLoaded(true);
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
}, [refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scriptLoaded) return;
|
||||
|
||||
const clientWindow = window as Window & { tinymce?: TinyMCE };
|
||||
|
||||
let tinyMCE = clientWindow.tinymce;
|
||||
|
||||
if (tinyMCE) {
|
||||
setTinyMCE(tinyMCE);
|
||||
} else {
|
||||
setRefresh((prev) => prev + 1);
|
||||
}
|
||||
}, [scriptLoaded]);
|
||||
|
||||
return { tinyMCE };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
const AceEditorModes = [
|
||||
"abap",
|
||||
"abc",
|
||||
"actionscript",
|
||||
"ada",
|
||||
"apache_conf",
|
||||
"asciidoc",
|
||||
"assembly_x86",
|
||||
"autohotkey",
|
||||
"batchfile",
|
||||
"c9search",
|
||||
"c_cpp",
|
||||
"cirru",
|
||||
"clojure",
|
||||
"cobol",
|
||||
"coffee",
|
||||
"coldfusion",
|
||||
"csharp",
|
||||
"css",
|
||||
"curly",
|
||||
"d",
|
||||
"dart",
|
||||
"diff",
|
||||
"dockerfile",
|
||||
"dot",
|
||||
"dummy",
|
||||
"dummysyntax",
|
||||
"eiffel",
|
||||
"ejs",
|
||||
"elixir",
|
||||
"elm",
|
||||
"erlang",
|
||||
"forth",
|
||||
"ftl",
|
||||
"gcode",
|
||||
"gherkin",
|
||||
"gitignore",
|
||||
"glsl",
|
||||
"golang",
|
||||
"groovy",
|
||||
"haml",
|
||||
"handlebars",
|
||||
"haskell",
|
||||
"haxe",
|
||||
"html",
|
||||
"html_ruby",
|
||||
"ini",
|
||||
"io",
|
||||
"jack",
|
||||
"jade",
|
||||
"java",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsoniq",
|
||||
"jsp",
|
||||
"jsx",
|
||||
"julia",
|
||||
"latex",
|
||||
"less",
|
||||
"liquid",
|
||||
"lisp",
|
||||
"livescript",
|
||||
"logiql",
|
||||
"lsl",
|
||||
"lua",
|
||||
"luapage",
|
||||
"lucene",
|
||||
"makefile",
|
||||
"markdown",
|
||||
"mask",
|
||||
"matlab",
|
||||
"mel",
|
||||
"mushcode",
|
||||
"mysql",
|
||||
"nix",
|
||||
"objectivec",
|
||||
"ocaml",
|
||||
"pascal",
|
||||
"perl",
|
||||
"pgsql",
|
||||
"php",
|
||||
"powershell",
|
||||
"praat",
|
||||
"prolog",
|
||||
"properties",
|
||||
"protobuf",
|
||||
"python",
|
||||
"r",
|
||||
"rdoc",
|
||||
"rhtml",
|
||||
"ruby",
|
||||
"rust",
|
||||
"sass",
|
||||
"scad",
|
||||
"scala",
|
||||
"scheme",
|
||||
"scss",
|
||||
"sh",
|
||||
"sjs",
|
||||
"smarty",
|
||||
"snippets",
|
||||
"soy_template",
|
||||
"space",
|
||||
"sql",
|
||||
"stylus",
|
||||
"svg",
|
||||
"tcl",
|
||||
"tex",
|
||||
"text",
|
||||
"textile",
|
||||
"toml",
|
||||
"twig",
|
||||
"typescript",
|
||||
"vala",
|
||||
"vbscript",
|
||||
"velocity",
|
||||
"verilog",
|
||||
"vhdl",
|
||||
"xml",
|
||||
"xquery",
|
||||
"yaml",
|
||||
"shell",
|
||||
] as const;
|
||||
|
||||
export default AceEditorModes;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
|
||||
componentRef?: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-border
|
||||
*/
|
||||
export default function Border({
|
||||
spacing,
|
||||
componentRef,
|
||||
...props
|
||||
}: TWUI_BORDER_PROPS) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border border-solid rounded-default",
|
||||
"border-slate-200 dark:border-white/10",
|
||||
spacing
|
||||
? spacing == "normal"
|
||||
? "px-3 py-2"
|
||||
: spacing == "tight"
|
||||
? "px-2 py-1"
|
||||
: ""
|
||||
: "px-3 py-2",
|
||||
"twui-border",
|
||||
props.className
|
||||
)}
|
||||
ref={componentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
import Link from "../layout/Link";
|
||||
import Divider from "../layout/Divider";
|
||||
import Row from "../layout/Row";
|
||||
import lowerToTitleCase from "../utils/lower-to-title-case";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
type LinkObject = {
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
excludeRegexMatch?: RegExp;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
currentLinkProps?: ComponentProps<typeof Link>;
|
||||
dividerProps?: ComponentProps<typeof Divider>;
|
||||
backButtonProps?: ComponentProps<typeof Button>;
|
||||
backButton?: boolean;
|
||||
pageUrl?: string;
|
||||
currentTitle?: string;
|
||||
skipHome?: boolean;
|
||||
divider?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* # TWUI Breadcrumbs
|
||||
* @className `twui-breadcrumb-link`
|
||||
* @className `twui-current-breadcrumb-wrapper`
|
||||
* @className `twui-breadcrumbs-divider`
|
||||
* @className `twui-breadcrumbs-back-button`
|
||||
*/
|
||||
export default function Breadcrumbs({
|
||||
excludeRegexMatch,
|
||||
linkProps,
|
||||
currentLinkProps,
|
||||
dividerProps,
|
||||
backButton,
|
||||
backButtonProps,
|
||||
pageUrl,
|
||||
currentTitle,
|
||||
skipHome,
|
||||
divider,
|
||||
}: Props) {
|
||||
const [links, setLinks] = React.useState<LinkObject[] | null>(
|
||||
pageUrl
|
||||
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
|
||||
: null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (links) return;
|
||||
|
||||
let pathname = window.location.pathname;
|
||||
|
||||
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
|
||||
url: pathname,
|
||||
excludeRegexMatch,
|
||||
skipHome,
|
||||
});
|
||||
|
||||
setLinks(validPathLinks);
|
||||
|
||||
return function () {
|
||||
setLinks(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!links?.[1]) {
|
||||
return <React.Fragment></React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={twMerge(
|
||||
"overflow-x-auto",
|
||||
"twui-current-breadcrumb-wrapper"
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
|
||||
)}
|
||||
>
|
||||
{backButton && (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
{...backButtonProps}
|
||||
className={twMerge(
|
||||
"p-1 -my-2 -mx-2",
|
||||
"twui-breadcrumbs-back-button",
|
||||
backButtonProps?.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
window.history.back();
|
||||
backButtonProps?.onClick?.(e);
|
||||
}}
|
||||
title="Breadcrumbs Back Button"
|
||||
beforeIcon={<ChevronLeft size={20} />}
|
||||
/>
|
||||
{divider || (
|
||||
<Divider
|
||||
vertical
|
||||
className={twMerge(
|
||||
"twui-breadcrumbs-divider",
|
||||
dividerProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{links.map((linkObject, index, array) => {
|
||||
const isTarget = array.length - 1 == index;
|
||||
|
||||
if (index === links.length - 1) {
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
|
||||
"max-w-[200px] text-ellipsis overflow-hidden",
|
||||
isTarget ? "current" : "",
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
title={
|
||||
currentLinkProps?.title || linkObject.title
|
||||
}
|
||||
>
|
||||
{currentTitle || linkObject.title}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-xs",
|
||||
isTarget ? "current" : "",
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
>
|
||||
{currentLinkProps?.title ||
|
||||
linkObject.title}
|
||||
</Link>
|
||||
{divider || (
|
||||
<Divider
|
||||
vertical
|
||||
{...dividerProps}
|
||||
className={twMerge(
|
||||
"twui-breadcrumbs-divider",
|
||||
dividerProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Row>
|
||||
</nav>
|
||||
);
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
}
|
||||
|
||||
export function twuiBreadcrumbsGenerateLinksFromUrl({
|
||||
url,
|
||||
excludeRegexMatch,
|
||||
skipHome,
|
||||
}: {
|
||||
url: string;
|
||||
excludeRegexMatch?: RegExp;
|
||||
skipHome?: boolean;
|
||||
}) {
|
||||
let pathLinks = url.split("/");
|
||||
|
||||
let validPathLinks = [];
|
||||
|
||||
if (!skipHome) {
|
||||
validPathLinks.push({
|
||||
title: "Home",
|
||||
path: url.match(/admin/) ? "/admin" : "/",
|
||||
});
|
||||
}
|
||||
|
||||
pathLinks.forEach((linkText, index, array) => {
|
||||
if (!linkText?.match(/./)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
|
||||
|
||||
validPathLinks.push({
|
||||
title: lowerToTitleCase(linkText),
|
||||
path: (() => {
|
||||
let path = "";
|
||||
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const lnText = array[i];
|
||||
if (i > index || !lnText.match(/./)) continue;
|
||||
|
||||
path += `/${lnText}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
})(),
|
||||
});
|
||||
});
|
||||
|
||||
return validPathLinks;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Link from "../layout/Link";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
variant?: "normal";
|
||||
href?: string;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
noHover?: boolean;
|
||||
elRef?: React.RefObject<HTMLDivElement>;
|
||||
linkRef?: React.RefObject<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # General Card
|
||||
* @className twui-card
|
||||
* @className twui-card-link
|
||||
*
|
||||
* @info use the classname `nested-link` to prevent the card from being clickable when
|
||||
* a link (or the target element with this calss) inside the card is clicked.
|
||||
*/
|
||||
export default function Card({
|
||||
href,
|
||||
variant,
|
||||
linkProps,
|
||||
noHover,
|
||||
elRef,
|
||||
linkRef,
|
||||
...props
|
||||
}: Props) {
|
||||
const component = (
|
||||
<div
|
||||
ref={elRef}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
|
||||
"border border-slate-200 dark:border-white/10 border-solid",
|
||||
noHover ? "" : "twui-card",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
ref={linkRef}
|
||||
href={href}
|
||||
{...linkProps}
|
||||
className={twMerge(
|
||||
"cursor-pointer",
|
||||
"twui-card",
|
||||
"twui-card-link",
|
||||
linkProps?.className
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
import Stack from "../layout/Stack copy";
|
||||
import Row from "../layout/Row";
|
||||
import { Check, CheckCircle, CheckCircle2 } from "lucide-react";
|
||||
import Span from "../layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type BulletPoint = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export type TWUI_CHECK_BULLET_POINTS_PROPS = ComponentProps<typeof Stack> & {
|
||||
bulletPoints: BulletPoint[];
|
||||
bulletWrapperProps?: ComponentProps<typeof Row>;
|
||||
iconProps?: ComponentProps<typeof CheckCircle2>;
|
||||
titleProps?: ComponentProps<typeof Span>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Check Bullet Points Component
|
||||
* @className_wrapper twui-check-bullet-points-wrapper
|
||||
*/
|
||||
export default function CheckBulletPoints({
|
||||
bulletPoints,
|
||||
bulletWrapperProps,
|
||||
iconProps,
|
||||
titleProps,
|
||||
...props
|
||||
}: TWUI_CHECK_BULLET_POINTS_PROPS) {
|
||||
return (
|
||||
<Stack {...props} className={twMerge("gap-3", props.className)}>
|
||||
{bulletPoints.map((bulletPoint, index) => {
|
||||
return (
|
||||
<Row
|
||||
key={index}
|
||||
{...bulletWrapperProps}
|
||||
className={twMerge(
|
||||
"gap-2 xl:flex-nowrap",
|
||||
bulletWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{bulletPoint.icon || (
|
||||
<CheckCircle2
|
||||
className="text-success min-w-[20px]"
|
||||
size={20}
|
||||
{...iconProps}
|
||||
/>
|
||||
)}
|
||||
<Span
|
||||
{...titleProps}
|
||||
className={twMerge("", titleProps?.className)}
|
||||
>
|
||||
{bulletPoint.title}
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import Button from "../layout/Button";
|
||||
import Divider from "../layout/Divider";
|
||||
|
||||
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
|
||||
|
||||
type Props = PropsWithChildren &
|
||||
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
"data-title"?: string;
|
||||
backgroundColor?: string;
|
||||
singleBlock?: boolean;
|
||||
language?: (typeof TWUIPrismLanguages)[number];
|
||||
};
|
||||
|
||||
/**
|
||||
* # CodeBlock
|
||||
*
|
||||
* @className `twui-code-block-wrapper`
|
||||
* @className `twui-code-pre-wrapper`
|
||||
* @className `twui-code-block-pre`
|
||||
* @className `twui-code-block-header`
|
||||
*/
|
||||
export default function CodeBlock({
|
||||
children,
|
||||
wrapperProps,
|
||||
backgroundColor,
|
||||
singleBlock,
|
||||
language,
|
||||
...props
|
||||
}: Props) {
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const title = props?.["data-title"];
|
||||
|
||||
const finalBackgroundColor = backgroundColor || "#28272b";
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||
`rounded w-full transition-all items-start`,
|
||||
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||
"twui-code-block-wrapper",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
style={{
|
||||
boxShadow: copied
|
||||
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||
: undefined,
|
||||
backgroundColor: finalBackgroundColor,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"gap-0 w-full overflow-x-auto relative",
|
||||
"max-h-[600px] overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full px-1 h-10 sticky top-0 py-2",
|
||||
singleBlock ? "absolute !bg-transparent" : "",
|
||||
"twui-code-block-header"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: finalBackgroundColor,
|
||||
}}
|
||||
>
|
||||
{title && <span className="text-white/70">{title}</span>}
|
||||
<div className="ml-auto">
|
||||
{copied ? (
|
||||
<Row>
|
||||
<span className="text-white text-xs twui-code-block-copied-text">
|
||||
Copied!
|
||||
</span>
|
||||
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
|
||||
<Check size={15} />
|
||||
</div>
|
||||
</Row>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
beforeIcon={<Copy size={17} color="white" />}
|
||||
className="!p-1 !bg-transparent opacity-50"
|
||||
onClick={() => {
|
||||
const content =
|
||||
codeRef.current?.textContent;
|
||||
if (!content) {
|
||||
window.alert("No Content to copy");
|
||||
return;
|
||||
}
|
||||
|
||||
window.navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
});
|
||||
}}
|
||||
title="Copy Code Snippet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{!singleBlock && (
|
||||
<Divider className="!border-white/10 sticky top-10" />
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
`p-1 w-full [&_pre]:!bg-transparent`,
|
||||
singleBlock ? "" : "-mt-1",
|
||||
"twui-code-pre-wrapper"
|
||||
)}
|
||||
ref={codeRef as any}
|
||||
>
|
||||
<pre
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"!my-0 whitespace-pre-wrap",
|
||||
language ? `language-${language}` : "",
|
||||
"twui-code-block-pre",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
active?: boolean;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
iconWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultScheme?: "light" | "dark";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Color Scheme Loader
|
||||
* @className_wrapper twui-color-scheme-selector
|
||||
*/
|
||||
export default function ColorSchemeSelector({
|
||||
active: initialActive,
|
||||
setActive: externalSetActive,
|
||||
iconWrapperProps,
|
||||
defaultScheme,
|
||||
...props
|
||||
}: Props) {
|
||||
const [active, setActive] = React.useState(initialActive);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isDocumentDark =
|
||||
document.documentElement.classList.contains("dark");
|
||||
const isDocumentLight =
|
||||
document.documentElement.classList.contains("light");
|
||||
|
||||
if (isDocumentDark) {
|
||||
setActive(true);
|
||||
return;
|
||||
} else if (isDocumentLight) {
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTheme = localStorage.getItem("theme");
|
||||
|
||||
if (existingTheme === "dark") {
|
||||
setActive(true);
|
||||
} else if (existingTheme === "light") {
|
||||
setActive(false);
|
||||
} else if (defaultScheme) {
|
||||
setActive(defaultScheme == "dark" ? false : true);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
setActive(true);
|
||||
} else if (typeof active == "undefined") {
|
||||
setActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof active == "undefined") return;
|
||||
|
||||
if (active) {
|
||||
document.documentElement.className = "dark";
|
||||
localStorage.setItem("theme", "dark");
|
||||
} else {
|
||||
document.documentElement.className = "light";
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center",
|
||||
"twui-color-scheme-selector",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<button
|
||||
title="Color Scheme Selector Button"
|
||||
onClick={() => setActive(!active)}
|
||||
className={twMerge(
|
||||
"cursor-pointer hover:opacity-70 flex items-center justify-center"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...iconWrapperProps}
|
||||
className={twMerge(
|
||||
"w-6 h-6 flex items-center justify-center",
|
||||
iconWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{active == false && <Sun />}
|
||||
{active == true && <Moon />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { Copy, LucideProps } from "lucide-react";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
|
||||
slugText: string;
|
||||
justIcon?: boolean;
|
||||
noIcon?: boolean;
|
||||
title?: string;
|
||||
outlined?: boolean;
|
||||
successMsg?: string | ReactNode;
|
||||
icon?: ReactNode;
|
||||
iconProps?: LucideProps;
|
||||
setToastOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function CopySlug({
|
||||
slugText,
|
||||
justIcon,
|
||||
noIcon,
|
||||
title,
|
||||
outlined,
|
||||
successMsg,
|
||||
iconProps,
|
||||
icon,
|
||||
setToastOpen,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title || slugText}
|
||||
size="smaller"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
navigator.clipboard.writeText(slugText).then(() => {
|
||||
setToastOpen?.(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setToastOpen?.(true);
|
||||
}, 100);
|
||||
});
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
|
||||
>
|
||||
{noIcon
|
||||
? null
|
||||
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
|
||||
{!justIcon && (title ? title : "Copy Slug")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const TWUIDropdownContentPositions = [
|
||||
"left",
|
||||
"bottom-left",
|
||||
"top-left",
|
||||
"top",
|
||||
"bottom",
|
||||
"right",
|
||||
"bottom-right",
|
||||
"top-right",
|
||||
"center",
|
||||
] as const;
|
||||
|
||||
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
||||
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
target: React.ReactNode;
|
||||
contentWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
targetWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
debounce?: number;
|
||||
openDebounce?: number;
|
||||
hoverOpen?: boolean;
|
||||
above?: boolean;
|
||||
position?: (typeof TWUIDropdownContentPositions)[number];
|
||||
topOffset?: number;
|
||||
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
externalOpen?: boolean;
|
||||
keepOpen?: boolean;
|
||||
disableClickActions?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-dropdown-wrapper
|
||||
* @className_wrapper twui-dropdown-target
|
||||
* @className_wrapper twui-dropdown-content
|
||||
*
|
||||
* @note use the class `cancel-link` to prevent popup open on click
|
||||
*/
|
||||
export default function Dropdown({
|
||||
contentWrapperProps,
|
||||
targetWrapperProps,
|
||||
hoverOpen,
|
||||
above,
|
||||
debounce = 200,
|
||||
openDebounce = 200,
|
||||
target,
|
||||
position = "center",
|
||||
topOffset,
|
||||
externalSetOpen,
|
||||
keepOpen,
|
||||
disableClickActions,
|
||||
externalOpen,
|
||||
...props
|
||||
}: TWUI_DROPDOWN_PROPS) {
|
||||
const [open, setOpen] = React.useState(externalOpen);
|
||||
|
||||
let timeout: any;
|
||||
let openTimeout: any;
|
||||
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
|
||||
|
||||
if (!closestWrapper) {
|
||||
externalSetOpen?.(false);
|
||||
return setOpen(false);
|
||||
}
|
||||
if (closestWrapper && closestWrapper !== dropdownRef.current) {
|
||||
externalSetOpen?.(false);
|
||||
return setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (keepOpen) return;
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(externalOpen);
|
||||
}, [externalOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center relative",
|
||||
"twui-dropdown-wrapper",
|
||||
props.className
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
openTimeout = setTimeout(() => {
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
}, openDebounce);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!hoverOpen) return;
|
||||
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
externalSetOpen?.(false);
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
if (targetEl?.closest?.(".cancel-link")) return;
|
||||
if (disableClickActions) return;
|
||||
externalSetOpen?.(!open);
|
||||
setOpen(!open);
|
||||
}}
|
||||
className={twMerge(
|
||||
"cursor-pointer",
|
||||
"twui-dropdown-target",
|
||||
targetWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...contentWrapperProps}
|
||||
className={twMerge(
|
||||
"absolute z-10 mt-1",
|
||||
position == "left"
|
||||
? "left-[100%] top-[50%] -translate-y-[50%]"
|
||||
: position == "right"
|
||||
? "right-[100%] top-[50%] -translate-y-[50%]"
|
||||
: position == "bottom-left"
|
||||
? "left-0 top-[100%]"
|
||||
: position == "bottom-right"
|
||||
? "right-0 top-[100%]"
|
||||
: position == "center"
|
||||
? "left-[50%] -translate-x-[50%] top-[100%]"
|
||||
: position == "top"
|
||||
? "left-[50%] -translate-x-[50%] bottom-[100%]"
|
||||
: "top-[100%]",
|
||||
above ? "-translate-y-[120%]" : "",
|
||||
open ? "flex" : "hidden",
|
||||
"twui-dropdown-content",
|
||||
contentWrapperProps?.className
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
style={{
|
||||
// top: `calc(100% + ${topOffset || 0}px)`,
|
||||
...contentWrapperProps?.style,
|
||||
}}
|
||||
ref={dropdownContentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "../layout/Stack";
|
||||
import Border from "./Border";
|
||||
import Center from "../layout/Center";
|
||||
import Row from "../layout/Row";
|
||||
import Span from "../layout/Span";
|
||||
import Link from "../layout/Link";
|
||||
|
||||
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||
export const ToastColors = ToastStyles;
|
||||
|
||||
export type TWUIEmptyContentProps = ComponentProps<typeof Stack> & {
|
||||
title: string;
|
||||
url?: string;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
borderProps?: ComponentProps<typeof Border>;
|
||||
textProps?: ComponentProps<typeof Span>;
|
||||
contentWrapperProps?: ComponentProps<typeof Row>;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* # EmptyC ontent Component
|
||||
* @className twui-empty-content
|
||||
* @className twui-empty-content-border
|
||||
* @className twui-empty-content-link
|
||||
*/
|
||||
export default function EmptyContent({
|
||||
title,
|
||||
url,
|
||||
linkProps,
|
||||
icon,
|
||||
borderProps,
|
||||
textProps,
|
||||
contentWrapperProps,
|
||||
...props
|
||||
}: TWUIEmptyContentProps) {
|
||||
const mainComponent = (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full", "twui-empty-content", props.className)}
|
||||
>
|
||||
<Border
|
||||
{...borderProps}
|
||||
className={twMerge(
|
||||
"w-full",
|
||||
borderProps?.className,
|
||||
"twui-empty-content-border"
|
||||
)}
|
||||
>
|
||||
<Center>
|
||||
<Row {...contentWrapperProps}>
|
||||
{icon && <div className="opacity-50">{icon}</div>}
|
||||
<Span
|
||||
size="small"
|
||||
{...textProps}
|
||||
className={twMerge(
|
||||
"opacity-70 text-foreground-light dark:text-foreground-dark",
|
||||
textProps?.className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Span>
|
||||
</Row>
|
||||
</Center>
|
||||
</Border>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<Link
|
||||
{...linkProps}
|
||||
className={twMerge(
|
||||
"w-full",
|
||||
"twui-empty-content-link",
|
||||
linkProps?.className
|
||||
)}
|
||||
href={url}
|
||||
>
|
||||
{mainComponent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return mainComponent;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import Link from "../layout/Link";
|
||||
import { TwuiHeaderLink } from "./HeaderNav";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
|
||||
export type TWUI_HEADER_LINK_PROPS = ComponentProps<typeof Link> & {
|
||||
link: TwuiHeaderLink;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Header Nav Component
|
||||
* @className_wrapper twui-header-link
|
||||
*/
|
||||
export default function HeaderLink({ link, ...props }: TWUI_HEADER_LINK_PROPS) {
|
||||
return (
|
||||
<Link
|
||||
href={link.url}
|
||||
strict={link.strict}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"grow p-2 hover:opacity-50",
|
||||
"twui-header-link",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Row>
|
||||
{link.icon}
|
||||
{link.title}
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
|
||||
|
||||
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
> & {
|
||||
headerLinks: TwuiHeaderLink[];
|
||||
customDropdowns?: {
|
||||
url: string;
|
||||
content: ReactNode;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TwuiHeaderLink = {
|
||||
title: string;
|
||||
url: string;
|
||||
strict?: boolean;
|
||||
dropdown?: ReactNode;
|
||||
children?: TwuiHeaderLink[];
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Header Nav Component
|
||||
* @className twui-header-nav
|
||||
* @className twui-header-nav-link-component
|
||||
* @className twui-header-nav-link-icon
|
||||
* @className twui-header-nav-link-dropdown
|
||||
*/
|
||||
export default function HeaderNav({
|
||||
headerLinks,
|
||||
customDropdowns,
|
||||
...props
|
||||
}: TWUI_HEADER_NAV_PROPS) {
|
||||
React.useEffect(() => {
|
||||
twuiAddActiveLinksFn({ selector: ".twui-header-nav-link-component a" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"twui-header-nav w-full xl:w-auto",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Row className="gap-x-2 gap-y-2 flex-col xl:flex-row items-start xl:items-stretch">
|
||||
{headerLinks.map((link, index) => {
|
||||
const targetCustomDropdown = customDropdowns?.find(
|
||||
(d) => d.url == link.url
|
||||
);
|
||||
return (
|
||||
<HeaderNavLinkComponent
|
||||
link={link}
|
||||
key={index}
|
||||
dropdown={targetCustomDropdown?.content}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type AddActiveLinkParams = {
|
||||
selector?: string;
|
||||
wrapperEl?: HTMLElement;
|
||||
};
|
||||
|
||||
export function twuiAddActiveLinksFn({
|
||||
selector,
|
||||
wrapperEl,
|
||||
}: AddActiveLinkParams) {
|
||||
(wrapperEl || document).querySelectorAll(selector || "a").forEach((ln) => {
|
||||
const linkEl = ln as HTMLAnchorElement;
|
||||
const isLinkStrict = linkEl.dataset.strict;
|
||||
|
||||
const linkAttr = linkEl.getAttribute("href");
|
||||
|
||||
if (window.location.pathname === "/" && linkAttr == "/") {
|
||||
linkEl.classList.add("active");
|
||||
} else if (
|
||||
isLinkStrict &&
|
||||
linkEl.getAttribute("href") === window.location.pathname
|
||||
) {
|
||||
linkEl.classList.add("active");
|
||||
} else if (
|
||||
linkAttr &&
|
||||
window.location.pathname.startsWith(linkAttr) &&
|
||||
!isLinkStrict
|
||||
) {
|
||||
linkEl.classList.add("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
import HeaderLink from "./HeaderLink";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Dropdown from "./Dropdown";
|
||||
import { TwuiHeaderLink } from "./HeaderNav";
|
||||
import Card from "./Card";
|
||||
import Stack from "../layout/Stack";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
/**
|
||||
* # Header Nav Main Link Component
|
||||
* @className twui-header-nav-link-component
|
||||
* @className twui-header-nav-link-icon
|
||||
* @className twui-header-nav-link-dropdown
|
||||
*/
|
||||
export default function HeaderNavLinkComponent({
|
||||
link,
|
||||
dropdown,
|
||||
}: {
|
||||
link: TwuiHeaderLink;
|
||||
dropdown?: ReactNode;
|
||||
}) {
|
||||
const isDropdown = dropdown || link.dropdown || link.children?.[0];
|
||||
|
||||
const mainLinkComponent = (
|
||||
<Row className="gap-0 grow">
|
||||
<HeaderLink link={link} strict={link.strict} />
|
||||
{isDropdown && (
|
||||
<ChevronDown
|
||||
className={twMerge(
|
||||
"hidden xl:flex xl:-ml-1",
|
||||
"twui-header-nav-link-icon"
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
|
||||
const [showMobileDropdown, setShowMobileDropdown] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative w-full xl:w-auto [&_a.active]:font-bold",
|
||||
"twui-header-nav-link-component"
|
||||
)}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<React.Fragment>
|
||||
<Stack className="flex xl:hidden w-full">
|
||||
<Row className="w-full justify-between">
|
||||
{mainLinkComponent}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setShowMobileDropdown(!showMobileDropdown)
|
||||
}
|
||||
title="Header Links Dropdown Button"
|
||||
>
|
||||
<ChevronDown
|
||||
className={twMerge(
|
||||
"twui-header-nav-link-icon !text-link dark:!text-white"
|
||||
)}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
{showMobileDropdown && (
|
||||
<Stack className="w-full">
|
||||
{dropdown ? (
|
||||
dropdown
|
||||
) : link.children?.[0] ? (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full p-0",
|
||||
"twui-header-nav-link-dropdown"
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full items-stretch gap-0 py-2">
|
||||
{link.children.map(
|
||||
(_ch, _index) => {
|
||||
return (
|
||||
<HeaderLink
|
||||
link={_ch}
|
||||
key={_index}
|
||||
className="px-6 py-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : link.dropdown ? (
|
||||
link.dropdown
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dropdown
|
||||
target={mainLinkComponent}
|
||||
position="center"
|
||||
hoverOpen
|
||||
className="hidden xl:flex"
|
||||
>
|
||||
{dropdown ? (
|
||||
dropdown
|
||||
) : link.children?.[0] ? (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"min-w-[200px] mt-2 p-0",
|
||||
"twui-header-nav-link-dropdown"
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full items-stretch gap-0 py-2">
|
||||
{link.children.map((_ch, _index) => {
|
||||
return (
|
||||
<HeaderLink
|
||||
link={_ch}
|
||||
key={_index}
|
||||
className="px-6 py-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : link.dropdown ? (
|
||||
link.dropdown
|
||||
) : null}
|
||||
</Dropdown>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
mainLinkComponent
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import HtmlToReact from "html-to-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
html: string;
|
||||
componentRef?: React.RefObject<any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # HTML String to React Component
|
||||
* @className_wrapper twui-html-react
|
||||
*/
|
||||
export default function HtmlToReactComponent({
|
||||
html,
|
||||
componentRef,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const htmlToReactParser = HtmlToReact.Parser();
|
||||
const reactElement = htmlToReactParser.parse(html);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge("", props.className)}
|
||||
ref={componentRef}
|
||||
>
|
||||
{reactElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Link from "../layout/Link";
|
||||
import { twuiAddActiveLinksFn } from "./HeaderNav";
|
||||
import Row from "../layout/Row";
|
||||
import Divider from "../layout/Divider";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
export type TWUI_LINK_LIST_LINK_OBJECT = {
|
||||
title?: string;
|
||||
component?: ReactNode;
|
||||
url?: string;
|
||||
strict?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: "before" | "after";
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
|
||||
linkType?: "button" | "link";
|
||||
divider?: ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
links: (
|
||||
| TWUI_LINK_LIST_LINK_OBJECT
|
||||
| TWUI_LINK_LIST_LINK_OBJECT[]
|
||||
| undefined
|
||||
)[];
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
|
||||
divider?: boolean;
|
||||
dividerComponent?: ReactNode;
|
||||
linkType?: "button" | "link";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Link List Component
|
||||
* @description A component that renders a list of links.
|
||||
* @className_wrapper twui-link-list
|
||||
*/
|
||||
export default function LinkList({
|
||||
links,
|
||||
linkProps,
|
||||
buttonProps,
|
||||
divider,
|
||||
dividerComponent,
|
||||
linkType,
|
||||
...props
|
||||
}: TWUI_LINK_LIST_PROPS) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
twuiAddActiveLinksFn({
|
||||
wrapperEl: listRef.current || undefined,
|
||||
selector: "a",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center gap-1",
|
||||
"twui-link-list",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{links
|
||||
.flat()
|
||||
.filter((ln) => Boolean(ln))
|
||||
.map((link, index) => {
|
||||
if (!link) return null;
|
||||
|
||||
if (link.divider)
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{link.divider}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
const finalDivider =
|
||||
index < links.length - 1 &&
|
||||
(dividerComponent ? (
|
||||
dividerComponent
|
||||
) : divider ? (
|
||||
<Divider />
|
||||
) : undefined);
|
||||
|
||||
if (linkType == "button" || link.linkType == "button") {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Button
|
||||
title={link.title || "Link Button"}
|
||||
variant="ghost"
|
||||
{...buttonProps}
|
||||
{...link.buttonProps}
|
||||
className={twMerge(
|
||||
"p-2 cursor-pointer whitespace-nowrap",
|
||||
linkProps?.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
link.onClick?.(e);
|
||||
link.buttonProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
{link.icon}
|
||||
{link.component || link.title}
|
||||
</Row>
|
||||
</Button>
|
||||
{finalDivider}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={link.url}
|
||||
title={link.title}
|
||||
{...linkProps}
|
||||
{...link.linkProps}
|
||||
className={twMerge(
|
||||
"p-2 cursor-pointer whitespace-nowrap",
|
||||
linkProps?.className,
|
||||
link.linkProps?.className,
|
||||
)}
|
||||
strict={link.strict}
|
||||
onClick={(e) => {
|
||||
link.onClick?.(e);
|
||||
link.linkProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
{!link.iconPosition ||
|
||||
link.iconPosition == "before"
|
||||
? link.icon
|
||||
: null}
|
||||
{link.component || link.title}
|
||||
{link.iconPosition == "after"
|
||||
? link.icon
|
||||
: null}
|
||||
</Row>
|
||||
</Link>
|
||||
{finalDivider}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { 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 React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # General paper
|
||||
* @className_wrapper twui-loading-block
|
||||
*/
|
||||
export default function LoadingBlock({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"bg-slate-200 dark:bg-white/10",
|
||||
"rounded animate-pulse w-full h-[60px]",
|
||||
"twui-loading-block",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Center from "../layout/Center";
|
||||
import Loading from "./Loading";
|
||||
import Row from "../layout/Row";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
loadingProps?: ComponentProps<typeof Loading>;
|
||||
label?: string;
|
||||
fixed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Loading Overlay Component
|
||||
* @className_wrapper twui-loading-overlay
|
||||
*/
|
||||
export default function LoadingOverlay({
|
||||
loadingProps,
|
||||
label,
|
||||
fixed,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"top-0 left-0 w-full h-full z-[500]",
|
||||
"bg-background-light/90 dark:bg-background-dark/90",
|
||||
fixed ? "fixed" : "absolute",
|
||||
props.className,
|
||||
"twui-loading-overlay",
|
||||
)}
|
||||
>
|
||||
<Center>
|
||||
<Row>
|
||||
<Loading {...loadingProps} />
|
||||
{label && <Span>{label}</Span>}
|
||||
</Row>
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import ModalComponent from "../(partials)/ModalComponent";
|
||||
import PopoverComponent from "../(partials)/PopoverComponent";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const TWUIPopoverStyles = [
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"transform",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
] as const;
|
||||
export const TWUIPopoverTriggers = ["hover", "click"] as const;
|
||||
|
||||
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
target?: React.ReactNode;
|
||||
targetRef?: React.RefObject<HTMLDivElement>;
|
||||
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
|
||||
targetWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
open?: boolean;
|
||||
isPopover?: boolean;
|
||||
position?: (typeof TWUIPopoverStyles)[number];
|
||||
trigger?: (typeof TWUIPopoverTriggers)[number];
|
||||
debounce?: number;
|
||||
onClose?: () => any;
|
||||
hoverOpen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Modal Component
|
||||
* @ID twui-modal-root
|
||||
* @className twui-modal-content
|
||||
* @className twui-modal
|
||||
* @ID twui-popover-root
|
||||
* @className twui-popover-content
|
||||
* @className twui-popover-target
|
||||
*/
|
||||
export default function Modal(props: TWUI_MODAL_PROPS) {
|
||||
const {
|
||||
target,
|
||||
targetRef,
|
||||
targetWrapperProps,
|
||||
open: existingOpen,
|
||||
setOpen: existingSetOpen,
|
||||
isPopover,
|
||||
popoverReferenceRef,
|
||||
trigger = "hover",
|
||||
debounce = 500,
|
||||
onClose,
|
||||
hoverOpen,
|
||||
} = props;
|
||||
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(existingOpen || false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
|
||||
const modalRoot = document.getElementById(IDName);
|
||||
|
||||
if (modalRoot) {
|
||||
if (isPopover) {
|
||||
modalRoot.style.zIndex = "1000";
|
||||
}
|
||||
setReady(true);
|
||||
} else {
|
||||
const newModalRootEl = document.createElement("div");
|
||||
newModalRootEl.id = IDName;
|
||||
document.body.appendChild(newModalRootEl);
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
existingSetOpen?.(open);
|
||||
if (open == false) onClose?.();
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(existingOpen || false);
|
||||
}, [existingOpen]);
|
||||
|
||||
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
|
||||
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
|
||||
|
||||
const popoverTargetActiveRef = React.useRef(false);
|
||||
const popoverContentActiveRef = React.useRef(false);
|
||||
|
||||
let closeTimeout: any;
|
||||
|
||||
const popoverEnterFn = React.useCallback((e: any) => {
|
||||
popoverTargetActiveRef.current = true;
|
||||
popoverContentActiveRef.current = false;
|
||||
setOpen(true);
|
||||
props.onMouseEnter?.(e);
|
||||
}, []);
|
||||
|
||||
const popoverLeaveFn = React.useCallback((e: any) => {
|
||||
window.clearTimeout(closeTimeout);
|
||||
closeTimeout = setTimeout(() => {
|
||||
// if (popoverTargetActiveRef.current) {
|
||||
// popoverTargetActiveRef.current = false;
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (popoverContentActiveRef.current) {
|
||||
popoverContentActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
props.onMouseLeave?.(e);
|
||||
}, []);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
const closestWrapper = targetEl.closest(".twui-popover-content");
|
||||
const closestTarget = targetEl.closest(".twui-popover-target");
|
||||
|
||||
if (closestTarget) return;
|
||||
|
||||
if (!closestWrapper) {
|
||||
return setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isPopover) return;
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{target ? (
|
||||
<div
|
||||
{...targetWrapperProps}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
ref={finalTargetRef}
|
||||
onMouseEnter={
|
||||
isPopover && (trigger === "hover" || hoverOpen)
|
||||
? popoverEnterFn
|
||||
: targetWrapperProps?.onMouseEnter
|
||||
}
|
||||
onMouseLeave={
|
||||
isPopover && (trigger === "hover" || hoverOpen)
|
||||
? popoverLeaveFn
|
||||
: targetWrapperProps?.onMouseLeave
|
||||
}
|
||||
className={twMerge(
|
||||
"twui-popover-target",
|
||||
targetWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
) : null}
|
||||
{ready ? (
|
||||
isPopover ? (
|
||||
<PopoverComponent
|
||||
{...props}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
targetElRef={finalPopoverReferenceRef}
|
||||
debounce={debounce}
|
||||
popoverTargetActiveRef={popoverTargetActiveRef}
|
||||
popoverContentActiveRef={popoverContentActiveRef}
|
||||
/>
|
||||
) : (
|
||||
<ModalComponent {...props} open={open} setOpen={setOpen} />
|
||||
)
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import _ from "lodash";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
import Button from "../layout/Button";
|
||||
import EmptyContent from "./EmptyContent";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
type Props = ComponentProps<typeof Row> & {
|
||||
page?: number;
|
||||
setPage?: Dispatch<SetStateAction<number>>;
|
||||
count?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Pagination Component
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export default function Pagination({
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
limit,
|
||||
...props
|
||||
}: Props) {
|
||||
if (!count || !page || !limit)
|
||||
return (
|
||||
<EmptyContent title={`count, page, and limit are all required`} />
|
||||
);
|
||||
|
||||
const isLimit = limit * page >= count;
|
||||
|
||||
const pages = Math.ceil(count / limit);
|
||||
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full justify-between flex-nowrap",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{pages > 1 && (
|
||||
<Button
|
||||
title="Next Page Button"
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
setPage?.((prev) => prev - 1);
|
||||
}}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={twMerge(
|
||||
"p-1",
|
||||
page == 1 ? "opacity-40 pointer-events-none" : ""
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Row className={twMerge("gap-6 w-full flex-nowrap justify-center")}>
|
||||
<Span size="small" variant="faded">
|
||||
Page {page} / {pages}
|
||||
</Span>
|
||||
{pages > 1 && (
|
||||
<Row
|
||||
className={twMerge(
|
||||
"flex-nowrap overflow-x-auto p-1 max-w-[90%]"
|
||||
)}
|
||||
>
|
||||
{Array(pages)
|
||||
.fill(0)
|
||||
.map((p, index) => {
|
||||
const isCurrent = page == index + 1;
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={`Page ${index + 1}`}
|
||||
onClick={() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
setPage?.(index + 1);
|
||||
}}
|
||||
variant={
|
||||
isCurrent ? "normal" : "outlined"
|
||||
}
|
||||
size="small"
|
||||
color={isCurrent ? "primary" : "gray"}
|
||||
className={twMerge(
|
||||
"p-1 w-6 h-6 min-w-6"
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{pages > 1 && (
|
||||
<Button
|
||||
title="Next Page Button"
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
setPage?.((prev) => prev + 1);
|
||||
}}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={twMerge(
|
||||
"p-1",
|
||||
isLimit ? "opacity-40 pointer-events-none" : ""
|
||||
)}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # General paper
|
||||
* @className_wrapper twui-paper
|
||||
*/
|
||||
export default function Paper({
|
||||
variant,
|
||||
linkProps,
|
||||
componentRef,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
variant?: "normal";
|
||||
linkProps?: DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
componentRef?: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={componentRef as any}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
|
||||
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||
"relative",
|
||||
"twui-paper",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import Modal, { TWUI_MODAL_PROPS } from "./Modal";
|
||||
|
||||
/**
|
||||
* # Popover Component
|
||||
*/
|
||||
export default function Popover(props: TWUI_MODAL_PROPS) {
|
||||
return <Modal {...props} isPopover />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { serialize } from "next-mdx-remote/serialize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypePrismPlus from "rehype-prism-plus";
|
||||
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import { useMDXComponents } from "../mdx/mdx-components";
|
||||
|
||||
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* # CodeBlock
|
||||
*
|
||||
* @className `twui-remote-code-block-wrapper`
|
||||
*/
|
||||
export default function RemoteCodeBlock({ content, refresh }: Props) {
|
||||
const [mdxSource, setMdxSource] =
|
||||
React.useState<MDXRemoteSerializeResult<any>>();
|
||||
|
||||
const { components } = useMDXComponents();
|
||||
|
||||
React.useEffect(() => {
|
||||
serialize(content, {
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypePrismPlus],
|
||||
},
|
||||
}).then((mdxSrc) => {
|
||||
setMdxSource(mdxSrc);
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
if (!mdxSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MDXRemote
|
||||
{...mdxSource}
|
||||
components={{
|
||||
...components,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Input, { InputProps } from "../form/Input";
|
||||
import Button from "../layout/Button";
|
||||
import Row from "../layout/Row";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
|
||||
let timeout: any;
|
||||
|
||||
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
dispatch?: (value?: string) => void;
|
||||
changeHandler?: (value?: string) => void;
|
||||
delay?: number;
|
||||
inputProps?: InputProps<KeyType>;
|
||||
buttonProps?: DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
componentRef?: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Search Component
|
||||
* @className_wrapper twui-search-wrapper
|
||||
* @className_circle twui-search-input
|
||||
* @className_circle twui-search-button
|
||||
*/
|
||||
export default function Search<KeyType extends string>({
|
||||
dispatch,
|
||||
changeHandler,
|
||||
delay = 500,
|
||||
inputProps,
|
||||
buttonProps,
|
||||
loading,
|
||||
placeholder,
|
||||
componentRef,
|
||||
...props
|
||||
}: SearchProps<KeyType>) {
|
||||
const [input, setInput] = React.useState(
|
||||
props.defaultValue?.toString() || ""
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
dispatch?.(input);
|
||||
changeHandler?.(input);
|
||||
}, delay);
|
||||
}, [input]);
|
||||
|
||||
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (props.autoFocus) {
|
||||
// inputRef.current?.focus();
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
|
||||
"twui-search-wrapper",
|
||||
props?.className
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={placeholder || "Search"}
|
||||
{...inputProps}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className={twMerge(
|
||||
"rounded-r-none!",
|
||||
"twui-search-input",
|
||||
inputProps?.className
|
||||
)}
|
||||
wrapperProps={{
|
||||
className: "rounded-r-none!",
|
||||
}}
|
||||
componentRef={inputRef}
|
||||
/>
|
||||
<Button
|
||||
loadingProps={{ size: "small" }}
|
||||
{...buttonProps}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className={twMerge(
|
||||
"rounded-l-none! ml-[1px]",
|
||||
"twui-search-button",
|
||||
buttonProps?.className
|
||||
)}
|
||||
onClick={() => {
|
||||
dispatch?.(input);
|
||||
changeHandler?.(input);
|
||||
}}
|
||||
title="Search Button"
|
||||
loading={loading}
|
||||
>
|
||||
<SearchIcon
|
||||
className="text-slate-800 dark:text-white"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { DetailedHTMLProps, PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = PropsWithChildren &
|
||||
DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
/**
|
||||
* # Single Line CodeBlock
|
||||
*/
|
||||
export default function SingleLineCodeBlock({ children, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"[&_.twui-code-block-header]:absolute [&_.twui-code-block-header]:!bg-transparent",
|
||||
"[&_.twui-code-block-header]:mt-2 [&_.twui-code-block-header]:pr-3 [&_.twui-divider]:hidden",
|
||||
"[&_pre]:!pr-14 [&_.twui-code-block-copied-text]:!hidden",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { LucideProps, Star } from "lucide-react";
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
ForwardRefExoticComponent,
|
||||
HTMLAttributes,
|
||||
RefAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type StarProps = {
|
||||
total?: number;
|
||||
value?: number;
|
||||
size?: number;
|
||||
starProps?: LucideProps;
|
||||
allowRating?: boolean;
|
||||
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
|
||||
changeHandler?: (value: number) => void;
|
||||
};
|
||||
|
||||
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> &
|
||||
StarProps;
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Star Rating Component
|
||||
* @className_wrapper twui-star-rating
|
||||
*/
|
||||
export default function StarRating({
|
||||
total = 5,
|
||||
value = 0,
|
||||
size,
|
||||
starProps,
|
||||
allowRating,
|
||||
setValueExternal,
|
||||
changeHandler,
|
||||
...props
|
||||
}: TWUI_STAR_RATING_PROPS) {
|
||||
const totalArray = Array(total).fill(null);
|
||||
|
||||
const [finalValue, setFinalValue] = React.useState(value);
|
||||
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
|
||||
|
||||
const starClicked = React.useRef(false);
|
||||
const sectionHovered = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
setValueExternal?.(finalValue);
|
||||
}, 500);
|
||||
}, [selectedStarValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center gap-0 -ml-[2px]",
|
||||
"twui-star-rating",
|
||||
props.className,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
sectionHovered.current = true;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
sectionHovered.current = false;
|
||||
}}
|
||||
>
|
||||
{totalArray.map((_, index) => {
|
||||
const isActive = index + 1 <= finalValue;
|
||||
|
||||
return (
|
||||
<StarComponent
|
||||
{...{
|
||||
total,
|
||||
value,
|
||||
size,
|
||||
starProps,
|
||||
index,
|
||||
allowRating,
|
||||
finalValue,
|
||||
setFinalValue,
|
||||
starClicked,
|
||||
selectedStarValue,
|
||||
sectionHovered,
|
||||
setSelectedStarValue,
|
||||
isActive,
|
||||
changeHandler,
|
||||
}}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarComponent({
|
||||
size = 20,
|
||||
starProps,
|
||||
index,
|
||||
allowRating,
|
||||
setFinalValue,
|
||||
starClicked,
|
||||
sectionHovered,
|
||||
setSelectedStarValue,
|
||||
selectedStarValue,
|
||||
isActive,
|
||||
changeHandler,
|
||||
}: StarProps & {
|
||||
index: number;
|
||||
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
|
||||
starClicked: React.MutableRefObject<boolean>;
|
||||
sectionHovered: React.MutableRefObject<boolean>;
|
||||
selectedStarValue: number;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
|
||||
onMouseEnter={() => {
|
||||
if (!allowRating) return;
|
||||
setFinalValue(index + 1);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!allowRating) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (sectionHovered.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!starClicked.current) {
|
||||
setFinalValue(0);
|
||||
}
|
||||
|
||||
if (selectedStarValue) {
|
||||
setFinalValue(selectedStarValue);
|
||||
}
|
||||
}, 200);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!allowRating) return;
|
||||
|
||||
starClicked.current = true;
|
||||
setSelectedStarValue(index + 1);
|
||||
changeHandler?.(index + 1);
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={size}
|
||||
className={twMerge(
|
||||
"text-slate-300 dark:text-white/20",
|
||||
isActive &&
|
||||
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
|
||||
// allowRating &&
|
||||
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
|
||||
starProps?.className,
|
||||
)}
|
||||
{...starProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Border from "./Border";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value?: string;
|
||||
content?: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
|
||||
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
centered?: boolean;
|
||||
debounce?: number;
|
||||
/**
|
||||
* React Component to display when switching
|
||||
*/
|
||||
switchComponent?: ReactNode;
|
||||
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
changeHandler?: (value: TWUITabsObject) => void;
|
||||
defaultValue?: string | null;
|
||||
hrefUpdate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tabs Component
|
||||
* @className twui-tabs-wrapper
|
||||
* @className twui-tab-buttons
|
||||
* @className twui-tab-button-active
|
||||
* @className twui-tab-buttons-wrapper
|
||||
* @className twui-tab-buttons-container
|
||||
* @className twui-tabs-border
|
||||
*/
|
||||
export default function Tabs({
|
||||
tabsContentArray,
|
||||
tabsBorderProps,
|
||||
tabsButtonsWrapperProps,
|
||||
centered,
|
||||
debounce = 100,
|
||||
switchComponent,
|
||||
setActiveValue: existingSetActiveValue,
|
||||
changeHandler,
|
||||
defaultValue,
|
||||
hrefUpdate,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const finalTabsContentArray = tabsContentArray
|
||||
.flat()
|
||||
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
|
||||
|
||||
const values = finalTabsContentArray.map(
|
||||
(obj) => obj.value || twuiSlugify(obj.title),
|
||||
);
|
||||
|
||||
const defaultActiveObj = finalTabsContentArray.find(
|
||||
(ctn) => ctn.defaultActive,
|
||||
);
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState(
|
||||
defaultValue
|
||||
? defaultValue
|
||||
: defaultActiveObj
|
||||
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||
: values[0] || undefined,
|
||||
);
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
const targetContent = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
existingSetActiveValue?.(activeValue);
|
||||
if (targetContent && activeValue) {
|
||||
changeHandler?.(targetContent);
|
||||
|
||||
if (hrefUpdate) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", activeValue);
|
||||
window.history.pushState({}, "", url);
|
||||
}
|
||||
}
|
||||
}, [activeValue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hrefUpdate) {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
const activeTab = url.searchParams.get("tab");
|
||||
|
||||
if (activeTab && activeValue !== activeTab) {
|
||||
setActiveValue(undefined);
|
||||
setActiveValue(activeTab);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setReady(true);
|
||||
}, 500);
|
||||
} else {
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
|
||||
>
|
||||
<div
|
||||
{...tabsButtonsWrapperProps}
|
||||
className={twMerge(
|
||||
"w-full",
|
||||
"twui-tab-buttons-wrapper",
|
||||
tabsButtonsWrapperProps?.className,
|
||||
)}
|
||||
>
|
||||
<Border
|
||||
className="p-0 w-full overflow-hidden twui-tabs-border"
|
||||
{...tabsBorderProps}
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
|
||||
centered && "justify-center",
|
||||
"twui-tab-buttons-container",
|
||||
)}
|
||||
>
|
||||
{values.map((value, index) => {
|
||||
const targetObject = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == value ||
|
||||
twuiSlugify(ctn.title) == value,
|
||||
);
|
||||
|
||||
const isActive = value == activeValue;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
|
||||
isActive
|
||||
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
|
||||
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
|
||||
" cursor-pointer",
|
||||
"twui-tab-buttons",
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveValue(undefined);
|
||||
setTimeout(() => {
|
||||
setActiveValue(value);
|
||||
}, debounce);
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{targetObject?.title}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Border>
|
||||
</div>
|
||||
{activeValue ? targetContent?.content : switchComponent || null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
content: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
||||
variant?: "normal" | "outlined" | "ghost";
|
||||
href?: string;
|
||||
newTab?: boolean;
|
||||
linkProps?: React.DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tabs Component
|
||||
* @className twui-tag
|
||||
* @className twui-tag-primary-outlined
|
||||
*/
|
||||
export default function Tag({
|
||||
color,
|
||||
variant,
|
||||
children,
|
||||
href,
|
||||
newTab,
|
||||
linkProps,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const mainComponent = (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||
"text-center flex items-center justify-center",
|
||||
color == "secondary"
|
||||
? "bg-secondary text-white outline-secbg-secondary"
|
||||
: color == "success"
|
||||
? "bg-success outline-success text-white"
|
||||
: color == "error"
|
||||
? "bg-orange-700 outline-orange-700"
|
||||
: color == "gray"
|
||||
? twMerge(
|
||||
"bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
|
||||
"text-slate-800 dark:text-white"
|
||||
)
|
||||
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
|
||||
variant == "outlined"
|
||||
? "!bg-transparent outline-1 " +
|
||||
(color == "secondary"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
|
||||
: variant == "ghost"
|
||||
? "!bg-transparent outline-none border-none " +
|
||||
(color == "secondary"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-primary dark:text-primary-dark")
|
||||
: "",
|
||||
|
||||
"twui-tag",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
{...linkProps}
|
||||
className={twMerge("hover:opacity-80", linkProps?.className)}
|
||||
>
|
||||
{mainComponent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return mainComponent;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Card from "./Card";
|
||||
import { X } from "lucide-react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||
export const ToastColors = ToastStyles;
|
||||
|
||||
export type TWUIToastProps = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
open?: boolean;
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeDispatch?: (open?: boolean) => void;
|
||||
closeDelay?: number;
|
||||
color?: (typeof ToastStyles)[number];
|
||||
};
|
||||
|
||||
let interval: any;
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Toast Component
|
||||
* @className twui-toast-root
|
||||
* @className twui-toast
|
||||
* @className twui-toast-success
|
||||
* @className twui-toast-error
|
||||
*/
|
||||
export default function Toast({
|
||||
open,
|
||||
setOpen,
|
||||
closeDelay = 4000,
|
||||
color,
|
||||
closeDispatch,
|
||||
...props
|
||||
}: TWUIToastProps) {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const IDName = "twui-toast-root";
|
||||
|
||||
React.useEffect(() => {
|
||||
const toastRoot = document.getElementById(IDName);
|
||||
|
||||
if (toastRoot) {
|
||||
setReady(true);
|
||||
} else {
|
||||
const newToastRootEl = document.createElement("div");
|
||||
newToastRootEl.id = IDName;
|
||||
document.body.appendChild(newToastRootEl);
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready || !open) return;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setOpen?.(false);
|
||||
closeDispatch?.(open);
|
||||
}, closeDelay);
|
||||
|
||||
return function () {
|
||||
setOpen?.(false);
|
||||
closeDispatch?.(open);
|
||||
};
|
||||
}, [ready, open]);
|
||||
|
||||
if (!ready) return null;
|
||||
if (!open) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<Card
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"fixed bottom-4 right-4 z-[250] border-none",
|
||||
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
|
||||
color == "success"
|
||||
? "bg-success-dark dark:bg-success-dark twui-toast-success"
|
||||
: color == "error"
|
||||
? "bg-error dark:bg-error-dark twui-toast-error"
|
||||
: "",
|
||||
props.className,
|
||||
"twui-toast",
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
timeout = setTimeout(() => {
|
||||
setOpen?.(false);
|
||||
closeDispatch?.(open);
|
||||
}, closeDelay);
|
||||
}}
|
||||
>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"absolute top-2 right-2 z-[100] cursor-pointer",
|
||||
"text-white",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen?.(false);
|
||||
closeDispatch?.(open);
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</Span>
|
||||
<Span className={twMerge("text-white! font-semibold")}>
|
||||
{props.children}
|
||||
</Span>
|
||||
</Card>,
|
||||
document.getElementById(IDName) as HTMLElement,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
active?: boolean;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
|
||||
circleProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-toggle-wrapper
|
||||
* @className_circle twui-toggle-circle
|
||||
*/
|
||||
export default function Toggle({
|
||||
circleProps,
|
||||
active,
|
||||
setActive,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
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,81 @@
|
||||
import { Send, X } from "lucide-react";
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import Row from "../../layout/Row";
|
||||
import Button from "../../layout/Button";
|
||||
import CopySlug from "../CopySlug";
|
||||
import AIPromptHistoryModal from "./AIPromptHistoryModal";
|
||||
|
||||
type Props = {
|
||||
streamRes: string;
|
||||
setStreamRes: Dispatch<SetStateAction<string>>;
|
||||
setPrompt: Dispatch<SetStateAction<string>>;
|
||||
loading: boolean;
|
||||
promptFn: (prompt: string) => void;
|
||||
history: ChatCompletionMessageParam[];
|
||||
prompt: string;
|
||||
currentPromptRef: React.MutableRefObject<string>;
|
||||
promptInputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export default function AIPromptActionSection({
|
||||
streamRes,
|
||||
setStreamRes,
|
||||
loading,
|
||||
promptFn,
|
||||
history,
|
||||
prompt,
|
||||
setPrompt,
|
||||
currentPromptRef,
|
||||
promptInputRef,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className="w-full justify-between">
|
||||
<Row className="gap-4">
|
||||
{streamRes.match(/./) && (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
title="Clear AI Result"
|
||||
variant="ghost"
|
||||
size="smaller"
|
||||
color="gray"
|
||||
className="px-0"
|
||||
beforeIcon={<X size={20} />}
|
||||
onClick={() => {
|
||||
setStreamRes("");
|
||||
}}
|
||||
/>
|
||||
<CopySlug
|
||||
slugText={streamRes}
|
||||
justIcon
|
||||
iconProps={{ size: 18 }}
|
||||
title="Copy Content"
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Row>
|
||||
<Row>
|
||||
<AIPromptHistoryModal history={history} />
|
||||
</Row>
|
||||
<Row>
|
||||
<Button
|
||||
title="Send Prompt"
|
||||
beforeIcon={<Send size={20} />}
|
||||
loading={loading}
|
||||
className="p-2"
|
||||
onClick={() => {
|
||||
currentPromptRef.current = prompt;
|
||||
setTimeout(() => {
|
||||
setPrompt("");
|
||||
if (promptInputRef.current) {
|
||||
promptInputRef.current.value = "";
|
||||
}
|
||||
}, 200);
|
||||
promptFn(prompt);
|
||||
}}
|
||||
loadingProps={{ size: "smaller" }}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import React from "react";
|
||||
import Paper from "../Paper";
|
||||
import Stack from "../../layout/Stack";
|
||||
import AIPromptPreview from "./AIPromptPreview";
|
||||
import LoadingOverlay from "../LoadingOverlay";
|
||||
import Textarea from "../../form/Textarea";
|
||||
import AIPromptActionSection from "./AIPromptActionSection";
|
||||
import Card from "../Card";
|
||||
import Row from "../../layout/Row";
|
||||
import Span from "../../layout/Span";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
model?: string;
|
||||
promptFn: (prompt: string) => void;
|
||||
history?: ChatCompletionMessageParam[];
|
||||
loading?: boolean;
|
||||
mdRes?: string;
|
||||
setMdRes: React.Dispatch<React.SetStateAction<string>>;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function AIPromptBlock({
|
||||
model,
|
||||
promptFn,
|
||||
history = [],
|
||||
loading = false,
|
||||
mdRes = "",
|
||||
setMdRes,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
const [prompt, setPrompt] = React.useState("");
|
||||
const currentPromptRef = React.useRef("");
|
||||
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
<Paper className="">
|
||||
<Stack className="w-full">
|
||||
{currentPromptRef.current && (
|
||||
<Row className="w-full justify-end">
|
||||
<Card className="py-1.5 px-2.5 text-xs">
|
||||
<Row>
|
||||
<Span>{currentPromptRef.current}</Span>
|
||||
<MessageCircleMore
|
||||
size={15}
|
||||
opacity={0.5}
|
||||
className="-mt-px"
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Row>
|
||||
)}
|
||||
<AIPromptPreview
|
||||
setStreamRes={setMdRes}
|
||||
streamRes={mdRes}
|
||||
history={history}
|
||||
loading={loading}
|
||||
/>
|
||||
<Stack className="w-full relative">
|
||||
{loading && <LoadingOverlay />}
|
||||
<Textarea
|
||||
placeholder={
|
||||
placeholder ||
|
||||
(model ? `Prompt ${model}` : "Prompt AI")
|
||||
}
|
||||
wrapperProps={{ className: "outline-none" }}
|
||||
wrapperWrapperProps={{ className: "w-full" }}
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
currentPromptRef.current = prompt;
|
||||
setTimeout(() => {
|
||||
setPrompt("");
|
||||
if (promptInputRef.current) {
|
||||
promptInputRef.current.value = "";
|
||||
}
|
||||
}, 200);
|
||||
promptFn(prompt);
|
||||
}
|
||||
}}
|
||||
componentRef={promptInputRef}
|
||||
// autoFocus
|
||||
/>
|
||||
<AIPromptActionSection
|
||||
loading={loading}
|
||||
promptFn={promptFn}
|
||||
setStreamRes={setMdRes}
|
||||
streamRes={mdRes}
|
||||
history={history}
|
||||
prompt={prompt}
|
||||
setPrompt={setPrompt}
|
||||
currentPromptRef={currentPromptRef}
|
||||
promptInputRef={promptInputRef as any}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import Modal from "../Modal";
|
||||
import Button from "../../layout/Button";
|
||||
import Stack from "../../layout/Stack";
|
||||
import H2 from "../../layout/H2";
|
||||
import Span from "../../layout/Span";
|
||||
import Divider from "../../layout/Divider";
|
||||
import Row from "../../layout/Row";
|
||||
import Card from "../Card";
|
||||
import Border from "../Border";
|
||||
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
|
||||
|
||||
type Props = {
|
||||
history: ChatCompletionMessageParam[];
|
||||
};
|
||||
|
||||
export default function AIPromptHistoryModal({ history }: Props) {
|
||||
if (!history[0]) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
title="View Chat History"
|
||||
size="smaller"
|
||||
color="gray"
|
||||
variant="outlined"
|
||||
>
|
||||
View History
|
||||
</Button>
|
||||
}
|
||||
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
|
||||
>
|
||||
<Stack className="gap-10 w-full">
|
||||
<Stack className="gap-1">
|
||||
<H2 className="!text-xl m-0">Chat History</H2>
|
||||
<Span className="text-xs">
|
||||
AI chat history for this session.
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{history.map((hst, index) => {
|
||||
if (hst.role == "user") {
|
||||
return (
|
||||
<Row
|
||||
key={index}
|
||||
className="w-full items-start justify-end"
|
||||
>
|
||||
<Card
|
||||
className={twMerge(
|
||||
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light"
|
||||
)}
|
||||
>
|
||||
{hst.content?.toString()}
|
||||
</Card>
|
||||
|
||||
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
|
||||
<User />
|
||||
</Border>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Row
|
||||
key={index}
|
||||
className="w-full items-start flex-nowrap"
|
||||
>
|
||||
<Stack>
|
||||
<Border
|
||||
className={twMerge(
|
||||
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
|
||||
"dark:bg-background-dark"
|
||||
)}
|
||||
>
|
||||
<Bot />
|
||||
</Border>
|
||||
</Stack>
|
||||
<Card className="grow overflow-x-auto xl:p-8">
|
||||
<MarkdownEditorPreviewComponent
|
||||
value={hst.content?.toString() || ""}
|
||||
maxHeight="none"
|
||||
wrapperProps={{
|
||||
className:
|
||||
"border-none p-0 ai-response-content w-full",
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Divider from "../../layout/Divider";
|
||||
import Stack from "../../layout/Stack";
|
||||
import React from "react";
|
||||
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
|
||||
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||
|
||||
type Props = {
|
||||
streamRes: string;
|
||||
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
|
||||
history: ChatCompletionMessageParam[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function AIPromptPreview({
|
||||
setStreamRes,
|
||||
streamRes,
|
||||
history,
|
||||
loading,
|
||||
}: Props) {
|
||||
const responseContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const isContentInterrupted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isContentInterrupted.current) return;
|
||||
|
||||
if (responseContentRef.current) {
|
||||
responseContentRef.current.scrollTop =
|
||||
responseContentRef.current.scrollHeight;
|
||||
}
|
||||
}, [streamRes]);
|
||||
|
||||
if (loading || !streamRes?.match(/./)) return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full">
|
||||
<MarkdownEditorPreviewComponent
|
||||
value={streamRes}
|
||||
maxHeight="40vh"
|
||||
wrapperProps={{
|
||||
className: "border-none p-0 ai-response-content",
|
||||
componentRef: responseContentRef as any,
|
||||
onMouseEnter: () => {
|
||||
isContentInterrupted.current = true;
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
isContentInterrupted.current = false;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import CheckMarkSVG from "../svgs/CheckMarkSVG";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import { Info } from "lucide-react";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type CheckboxProps = Omit<
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>,
|
||||
"title"
|
||||
> & {
|
||||
title?: string | ReactNode;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
labelProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultChecked?: boolean;
|
||||
wrapperClassName?: string;
|
||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
checked?: boolean;
|
||||
readOnly?: boolean;
|
||||
noLabel?: boolean;
|
||||
size?: number;
|
||||
changeHandler?: (value: boolean) => void;
|
||||
info?: string | ReactNode;
|
||||
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Checkbox Component
|
||||
* @className twui-checkbox
|
||||
* @className twui-checkbox-checked
|
||||
* @className twui-checkbox-unchecked
|
||||
*/
|
||||
export default function Checkbox({
|
||||
wrapperProps,
|
||||
label,
|
||||
labelProps,
|
||||
size,
|
||||
wrapperClassName,
|
||||
defaultChecked,
|
||||
setChecked: externalSetChecked,
|
||||
readOnly,
|
||||
checked: externalChecked,
|
||||
changeHandler,
|
||||
info,
|
||||
wrapperWrapperProps,
|
||||
noLabel,
|
||||
title,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
|
||||
const [checked, setChecked] = React.useState(
|
||||
defaultChecked || externalChecked || false,
|
||||
);
|
||||
|
||||
const finalTitle = title
|
||||
? title
|
||||
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof externalChecked == "undefined") return;
|
||||
setChecked(externalChecked);
|
||||
}, [externalChecked]);
|
||||
|
||||
React.useEffect(() => {
|
||||
changeHandler?.(checked);
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
|
||||
readOnly ? "opacity-70 pointer-events-none" : "",
|
||||
wrapperClassName,
|
||||
wrapperProps?.className,
|
||||
)}
|
||||
onClick={() => {
|
||||
setChecked(!checked);
|
||||
externalSetChecked?.(!checked);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex items-center justify-center p-[3px] rounded-default",
|
||||
checked
|
||||
? "bg-primary twui-checkbox-checked text-white outline-slate-400"
|
||||
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
|
||||
"twui-checkbox",
|
||||
props.className,
|
||||
)}
|
||||
style={{
|
||||
minWidth: finalSize + "px",
|
||||
width: finalSize + "px",
|
||||
height: finalSize + "px",
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
{checked && <CheckMarkSVG />}
|
||||
</div>
|
||||
{!noLabel && (
|
||||
<Stack className="gap-0.5">
|
||||
<div
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"select-none whitespace-normal md:whitespace-nowrap",
|
||||
labelProps?.className,
|
||||
)}
|
||||
>
|
||||
{label || finalTitle}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
{info && (
|
||||
<Row className="gap-1 flex-nowrap" title={info.toString()}>
|
||||
<Info size={13} className="opacity-40 min-w-[20px]" />
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{info}
|
||||
</Span>
|
||||
</Row>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { FileArchive, FilePlus2, X } from "lucide-react";
|
||||
import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import fileInputToBase64 from "../utils/form/fileInputToBase64";
|
||||
import Row from "../layout/Row";
|
||||
import Input from "./Input";
|
||||
import Loading from "../elements/Loading";
|
||||
import Tag from "../elements/Tag";
|
||||
|
||||
type FileInputUtils = {
|
||||
clearFileInput?: () => void;
|
||||
};
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChangeHandler?: (
|
||||
fileData: FileInputToBase64FunctionReturn | undefined,
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>,
|
||||
utils?: FileInputUtils,
|
||||
) => any;
|
||||
changeHandler?: (
|
||||
fileData: FileInputToBase64FunctionReturn | undefined,
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>,
|
||||
utils?: FileInputUtils,
|
||||
) => any;
|
||||
onClear?: () => void;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageWrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageProps?: DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
disablePreview?: boolean;
|
||||
allowedRegex?: RegExp;
|
||||
externalSetFile?: React.Dispatch<
|
||||
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
|
||||
>;
|
||||
externalSetFiles?: React.Dispatch<
|
||||
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
|
||||
>;
|
||||
existingFile?: FileInputToBase64FunctionReturn;
|
||||
existingFileUrl?: string;
|
||||
icon?: ReactNode;
|
||||
externalSetFileURL?: React.Dispatch<string | undefined>;
|
||||
labelSpanProps?: ComponentProps<typeof Span>;
|
||||
loading?: boolean;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
|
||||
*/
|
||||
export default function FileUpload({
|
||||
onChangeHandler,
|
||||
fileInputProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
allowedRegex,
|
||||
externalSetFile,
|
||||
externalSetFiles,
|
||||
existingFile,
|
||||
existingFileUrl,
|
||||
icon,
|
||||
labelSpanProps,
|
||||
loading,
|
||||
multiple,
|
||||
onClear,
|
||||
changeHandler,
|
||||
externalSetFileURL,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [file, setFile] = React.useState<
|
||||
FileInputToBase64FunctionReturn | undefined
|
||||
>(existingFile);
|
||||
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
|
||||
existingFileUrl,
|
||||
);
|
||||
|
||||
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const tempFileURLRef = React.useRef<string>("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingFileUrl) {
|
||||
setFileUrl(existingFileUrl);
|
||||
}
|
||||
}, [existingFileUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingFile) {
|
||||
setFile(existingFile);
|
||||
}
|
||||
}, [existingFile]);
|
||||
|
||||
function clearFileInput() {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
const fileInputUtils: FileInputUtils = { clearFileInput };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full h-[300px]", props?.className)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className={twMerge("hidden", fileInputProps?.className)}
|
||||
{...fileInputProps}
|
||||
onChange={(e) => {
|
||||
if (multiple) {
|
||||
(async () => {
|
||||
const files = e.target.files;
|
||||
if (!files?.[0]) return;
|
||||
|
||||
let filesArr: FileInputToBase64FunctionReturn[] =
|
||||
[];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fileObj = await fileInputToBase64({
|
||||
inputFile: file,
|
||||
});
|
||||
filesArr.push(fileObj);
|
||||
}
|
||||
|
||||
externalSetFiles?.(filesArr);
|
||||
})();
|
||||
} else {
|
||||
const inputFile = e.target.files?.[0];
|
||||
|
||||
if (!inputFile) return;
|
||||
|
||||
fileInputToBase64({ inputFile, allowedRegex }).then(
|
||||
(res) => {
|
||||
setFile(res);
|
||||
externalSetFile?.(res);
|
||||
onChangeHandler?.(
|
||||
res,
|
||||
inputRef,
|
||||
fileInputUtils,
|
||||
);
|
||||
changeHandler?.(res, inputRef, fileInputUtils);
|
||||
fileInputProps?.onChange?.(e);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
ref={inputRef as any}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card className={twMerge("w-full h-full ")}>
|
||||
<Center>
|
||||
<Loading />
|
||||
</Center>
|
||||
</Card>
|
||||
) : file ? (
|
||||
<Card
|
||||
{...previewImageWrapperProps}
|
||||
className={twMerge(
|
||||
"w-full relative h-full items-center justify-center overflow-hidden",
|
||||
"pb-10",
|
||||
previewImageWrapperProps?.className,
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : file.fileType?.match(/image/i) ? (
|
||||
<img
|
||||
src={file.fileBase64Full}
|
||||
className="w-full object-contain overflow-hidden"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
) : (
|
||||
<Stack>
|
||||
<FileArchive size={36} strokeWidth={1} />
|
||||
<Stack className="gap-0">
|
||||
<Span>
|
||||
{file.file?.name || file.fileName}
|
||||
</Span>
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{file.fileType}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={twMerge(
|
||||
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
|
||||
"hover:bg-white dark:hover:bg-black",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
clearFileInput();
|
||||
}}
|
||||
title="Cancel File Upload Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
<Input
|
||||
value={file.fileName}
|
||||
onChange={(e) => {
|
||||
setFile({ ...file, fileName: e.target.value });
|
||||
externalSetFile?.({
|
||||
...file,
|
||||
fileName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : fileUrl ? (
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center overflow-hidden"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
<Stack className="w-full">
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
||||
<Row>
|
||||
<FileArchive size={36} strokeWidth={1} />
|
||||
<Stack className="gap-0">
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{fileUrl}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
) : (
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="w-full object-contain overflow-hidden"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className="w-full py-2 text-sm"
|
||||
>
|
||||
{fileUrl}
|
||||
</Tag>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={twMerge(
|
||||
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
|
||||
"hover:bg-white dark:hover:bg-black",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
setFileUrl(undefined);
|
||||
}}
|
||||
title="Cancel File Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
|
||||
"border-dashed border-2",
|
||||
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
|
||||
placeHolderWrapper?.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
inputRef.current?.click();
|
||||
placeHolderWrapper?.onClick?.(e);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setFileDraggedOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
setFileDraggedOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setFileDraggedOver(false);
|
||||
let inputFile: File | null = null;
|
||||
|
||||
if (e.dataTransfer.items) {
|
||||
[...e.dataTransfer.items].forEach((item, i) => {
|
||||
if (inputFile) return;
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
inputFile = file;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
inputFile = e.dataTransfer.files?.[0];
|
||||
}
|
||||
|
||||
if (!inputFile) return;
|
||||
|
||||
fileInputToBase64({ inputFile, allowedRegex }).then(
|
||||
(res) => {
|
||||
setFile(res);
|
||||
externalSetFile?.(res);
|
||||
onChangeHandler?.(res);
|
||||
changeHandler?.(res);
|
||||
},
|
||||
);
|
||||
}}
|
||||
{...placeHolderWrapper}
|
||||
>
|
||||
<Center
|
||||
className={twMerge(
|
||||
fileDraggedOver ? "pointer-events-none" : "",
|
||||
)}
|
||||
>
|
||||
<Stack className="items-center gap-2">
|
||||
{icon || <FilePlus2 className="text-slate-400" />}
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
{...labelSpanProps}
|
||||
>
|
||||
{label || "Click to Upload File"}
|
||||
</Span>
|
||||
{externalSetFileURL ? (
|
||||
<Row
|
||||
className="flex-nowrap gap-0 items-stretch"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Add Media URL"
|
||||
className="text-sm"
|
||||
wrapperProps={{ className: "h-full" }}
|
||||
wrapperWrapperProps={{
|
||||
className: "h-full",
|
||||
}}
|
||||
changeHandler={(value) => {
|
||||
tempFileURLRef.current = value;
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
if (tempFileURLRef.current) {
|
||||
setFileUrl(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
externalSetFileURL(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title="Add Media URL"
|
||||
variant="outlined"
|
||||
className="py-0 px-3"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (tempFileURLRef.current) {
|
||||
setFileUrl(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
externalSetFileURL(
|
||||
tempFileURLRef.current,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl">+</span>
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, FormHTMLAttributes, RefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
|
||||
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
formRef?: RefObject<HTMLFormElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Form Element
|
||||
* @className twui-form
|
||||
*/
|
||||
export default function Form<
|
||||
T extends { [key: string]: any } = { [key: string]: any },
|
||||
>({ formRef, ...props }: Props<T>) {
|
||||
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
|
||||
|
||||
return (
|
||||
<form
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-stretch gap-2 w-full bg-transparent",
|
||||
"twui-form",
|
||||
props.className,
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formEl = e.target as HTMLFormElement;
|
||||
const formData = new FormData(formEl);
|
||||
const data = Object.fromEntries(formData.entries()) as T;
|
||||
props.submitHandler?.(e, data);
|
||||
props.onSubmit?.(e);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
const taregtEl = e.target as HTMLElement;
|
||||
const formEl = taregtEl.closest("form") as HTMLFormElement;
|
||||
const formData = new FormData(formEl);
|
||||
const data = Object.fromEntries(formData.entries()) as T;
|
||||
props.changeHandler?.(e, data);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { ImagePlus, X } from "lucide-react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import imageInputToBase64, {
|
||||
ImageInputToBase64FunctionReturn,
|
||||
} from "../utils/form/imageInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "../elements/Tag";
|
||||
import Input from "./Input";
|
||||
import Row from "../layout/Row";
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChangeHandler?: (
|
||||
imgData: ImageInputToBase64FunctionReturn | undefined,
|
||||
) => any;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageWrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
previewImageProps?: DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
label?: string;
|
||||
disablePreview?: boolean;
|
||||
multiple?: boolean;
|
||||
existingImageUrl?: string;
|
||||
externalSetImage?: React.Dispatch<
|
||||
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
|
||||
>;
|
||||
externalSetImages?: React.Dispatch<
|
||||
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
|
||||
>;
|
||||
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
externalImage?: ImageInputToBase64FunctionReturn;
|
||||
restoreImageFn?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
|
||||
*/
|
||||
export default function ImageUpload({
|
||||
onChangeHandler,
|
||||
fileInputProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
existingImageUrl,
|
||||
externalSetImage,
|
||||
externalSetImages,
|
||||
externalImage,
|
||||
multiple,
|
||||
restoreImageFn,
|
||||
setLoading,
|
||||
setImgURL,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [imageObject, setImageObject] = React.useState<
|
||||
ImageInputToBase64FunctionReturn | undefined
|
||||
>(externalImage);
|
||||
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const imageUrlRef = React.useRef("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setImgURL?.(src);
|
||||
}, [src]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (existingImageUrl) setSrc(existingImageUrl);
|
||||
}, [existingImageUrl]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full h-[300px] overflow-hidden",
|
||||
props?.className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
className={twMerge("hidden", fileInputProps?.className)}
|
||||
multiple={multiple}
|
||||
accept="image/*"
|
||||
{...fileInputProps}
|
||||
onChange={(e) => {
|
||||
setLoading?.(true);
|
||||
|
||||
if (multiple) {
|
||||
(async () => {
|
||||
const files = e.target.files;
|
||||
if (!files?.[0]) return;
|
||||
|
||||
let imgArr: ImageInputToBase64FunctionReturn[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fileObj = await imageInputToBase64({
|
||||
file,
|
||||
});
|
||||
imgArr.push(fileObj);
|
||||
}
|
||||
|
||||
externalSetImages?.(imgArr);
|
||||
setLoading?.(false);
|
||||
})();
|
||||
} else {
|
||||
imageInputToBase64({ imageInput: e.target }).then(
|
||||
(res) => {
|
||||
setSrc(res.imageBase64Full);
|
||||
onChangeHandler?.(res);
|
||||
setImageObject?.(res);
|
||||
externalSetImage?.(res);
|
||||
fileInputProps?.onChange?.(e);
|
||||
setLoading?.(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
ref={inputRef as any}
|
||||
/>
|
||||
|
||||
{src || imageObject?.imageBase64Full ? (
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 text-xs z-50",
|
||||
)}
|
||||
>
|
||||
<Tag color="gray">
|
||||
<span className="opacity-70">{label}</span>
|
||||
</Tag>
|
||||
</label>
|
||||
)}
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : (
|
||||
<img
|
||||
src={imageObject?.imageBase64Full || src}
|
||||
className="w-full h-full object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setSrc(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
setImageObject?.(undefined);
|
||||
externalSetImage?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value == "";
|
||||
}
|
||||
}}
|
||||
title="Cancel Image Upload Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
|
||||
placeHolderWrapper?.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement | undefined;
|
||||
if (targetEl?.closest(".cancel-upload")) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
placeHolderWrapper?.onClick?.(e);
|
||||
}}
|
||||
{...placeHolderWrapper}
|
||||
>
|
||||
<Center>
|
||||
<Stack className="items-center gap-2">
|
||||
<ImagePlus className="text-slate-400" />
|
||||
<Span size="smaller" variant="faded">
|
||||
{label || "Click to Upload Image"}
|
||||
</Span>
|
||||
<Stack className="cancel-upload w-full items-stretch gap-1">
|
||||
<Input
|
||||
placeholder="Eg. https://example.com/img.png"
|
||||
className="text-sm twui-image-url-input"
|
||||
title="Enter Image URL"
|
||||
wrapperWrapperProps={{ className: "mt-2" }}
|
||||
changeHandler={(value) => {
|
||||
imageUrlRef.current = value;
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
<Button
|
||||
title="Restore Image Button"
|
||||
size="smaller"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
if (!imageUrlRef.current) return;
|
||||
setSrc(imageUrlRef.current);
|
||||
}}
|
||||
>
|
||||
Set Image URL
|
||||
</Button>
|
||||
</Stack>
|
||||
{existingImageUrl && (
|
||||
<Button
|
||||
title="Restore Image Button"
|
||||
size="smaller"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restoreImageFn?.() ||
|
||||
setSrc(existingImageUrl);
|
||||
}}
|
||||
className="cancel-upload"
|
||||
>
|
||||
Restore Original Image
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Row from "../../layout/Row";
|
||||
import { Info, Minus, Plus } from "lucide-react";
|
||||
import twuiNumberfy from "../../utils/numberfy";
|
||||
import { InputProps } from ".";
|
||||
|
||||
let pressInterval: any;
|
||||
let pressTimeout: any;
|
||||
|
||||
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
|
||||
value: string;
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
buttonDownRef: React.MutableRefObject<boolean>;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Input Number Text Buttons
|
||||
*/
|
||||
export default function NumberInputButtons({
|
||||
value,
|
||||
setValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
buttonDownRef,
|
||||
inputRef,
|
||||
decimal,
|
||||
}: Props) {
|
||||
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||
const DEFAULT_STEP = 1;
|
||||
|
||||
const [buttonDown, setButtonDown] = useState(false);
|
||||
|
||||
// function getNormalizedValue(value: string) {
|
||||
// if (numberText) {
|
||||
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
||||
// return getFinalValue(props.max);
|
||||
|
||||
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
||||
// return getFinalValue(props.min);
|
||||
|
||||
// return getFinalValue(value);
|
||||
// } else {
|
||||
// return value;
|
||||
// }
|
||||
// }
|
||||
|
||||
useEffect(() => {
|
||||
buttonDownRef.current = buttonDown;
|
||||
if (buttonDown) {
|
||||
setValue(inputRef.current?.value || "");
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setValue(inputRef.current?.value || "");
|
||||
}, 50);
|
||||
}
|
||||
}, [buttonDown]);
|
||||
|
||||
function incrementDownPress() {
|
||||
window.clearTimeout(pressTimeout);
|
||||
setButtonDown(true);
|
||||
|
||||
pressTimeout = setTimeout(() => {
|
||||
pressInterval = setInterval(() => {
|
||||
increment();
|
||||
}, 50);
|
||||
}, PRESS_TRIGGER_TIMEOUT);
|
||||
}
|
||||
|
||||
function incrementDownCancel() {
|
||||
setButtonDown(false);
|
||||
window.clearTimeout(pressTimeout);
|
||||
window.clearInterval(pressInterval);
|
||||
}
|
||||
|
||||
function decrementDownPress() {
|
||||
setButtonDown(true);
|
||||
|
||||
pressTimeout = setTimeout(() => {
|
||||
pressInterval = setInterval(() => {
|
||||
decrement();
|
||||
}, 50);
|
||||
}, PRESS_TRIGGER_TIMEOUT);
|
||||
}
|
||||
|
||||
function decrementDownCancel() {
|
||||
setButtonDown(false);
|
||||
window.clearTimeout(pressTimeout);
|
||||
window.clearInterval(pressInterval);
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
const existingValue = inputRef.current.value;
|
||||
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||
|
||||
let new_value = "";
|
||||
|
||||
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
|
||||
new_value = twuiNumberfy(max, decimal).toLocaleString();
|
||||
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
|
||||
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||
} else {
|
||||
new_value = (
|
||||
existingNumberValue +
|
||||
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||
).toLocaleString();
|
||||
}
|
||||
|
||||
inputRef.current.value = new_value;
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
const existingValue = inputRef.current?.value;
|
||||
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||
|
||||
let new_value = "";
|
||||
|
||||
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
|
||||
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||
} else {
|
||||
new_value = (
|
||||
existingNumberValue -
|
||||
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||
).toLocaleString();
|
||||
}
|
||||
|
||||
inputRef.current.value = new_value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2">
|
||||
<Row
|
||||
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
decrement();
|
||||
}}
|
||||
onMouseDown={decrementDownPress}
|
||||
onTouchStart={decrementDownPress}
|
||||
onMouseUp={decrementDownCancel}
|
||||
onTouchEnd={decrementDownCancel}
|
||||
>
|
||||
<Minus size={20} />
|
||||
</Row>
|
||||
<Row
|
||||
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
increment();
|
||||
}}
|
||||
onMouseDown={incrementDownPress}
|
||||
onTouchStart={incrementDownPress}
|
||||
onMouseUp={incrementDownCancel}
|
||||
onTouchEnd={incrementDownCancel}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
LabelHTMLAttributes,
|
||||
} from "react";
|
||||
import Row from "../layout/Row";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Value = {
|
||||
value: string;
|
||||
title?: string;
|
||||
default?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_FORM_RADIO_PROPS = {
|
||||
values: Value[];
|
||||
name: string;
|
||||
inputProps?: DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
wrapperProps?: ComponentProps<typeof Row>;
|
||||
changeHandler?: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Form Radios Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Radios({
|
||||
values,
|
||||
name,
|
||||
inputProps,
|
||||
labelProps,
|
||||
wrapperProps,
|
||||
changeHandler,
|
||||
}: TWUI_FORM_RADIO_PROPS) {
|
||||
const finalName = twuiSlugify(name);
|
||||
const finalTitle = twuiSlugToNormalText(finalName);
|
||||
|
||||
return (
|
||||
<Row
|
||||
title={finalTitle}
|
||||
{...wrapperProps}
|
||||
className={twMerge("gap-4", wrapperProps?.className)}
|
||||
>
|
||||
{values.map((v, i) => {
|
||||
const valueName = twuiSlugify(`${finalName}-${v.value}`);
|
||||
const valueTitle = v.title || twuiSlugToNormalText(v.value);
|
||||
|
||||
return (
|
||||
<Row key={i} className="gap-1.5">
|
||||
<input
|
||||
id={valueName}
|
||||
type="radio"
|
||||
defaultChecked={v.default}
|
||||
name={finalName}
|
||||
onChange={(e) => {
|
||||
const targetValue = v.value;
|
||||
changeHandler?.(targetValue);
|
||||
}}
|
||||
{...inputProps}
|
||||
/>
|
||||
<label htmlFor={valueName} {...labelProps}>
|
||||
{valueTitle}
|
||||
</label>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ChevronDown, Info, LucideProps, Search } from "lucide-react";
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import Stack from "../layout/Stack";
|
||||
import {
|
||||
TWUISelectOptionObject,
|
||||
TWUISelectProps,
|
||||
TWUISelectValidityObject,
|
||||
} from "./Select";
|
||||
import Border from "../elements/Border";
|
||||
import Input from "./Input";
|
||||
import Paper from "../elements/Paper";
|
||||
import Button from "../layout/Button";
|
||||
import Divider from "../layout/Divider";
|
||||
|
||||
/**
|
||||
* # Search Select Element
|
||||
* @className twui-search-select-wrapper
|
||||
* @className twui-search-select
|
||||
* @className twui-search-select-dropdown-icon
|
||||
*/
|
||||
export default function SearchSelect<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
>({
|
||||
label,
|
||||
options,
|
||||
componentRef,
|
||||
labelProps,
|
||||
wrapperProps,
|
||||
showLabel,
|
||||
iconProps,
|
||||
changeHandler,
|
||||
info,
|
||||
validateValueFn,
|
||||
wrapperWrapperProps,
|
||||
dispatchState,
|
||||
...props
|
||||
}: TWUISelectProps<KeyType, T>) {
|
||||
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
|
||||
|
||||
const [currentOptions, setCurrentOptions] =
|
||||
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
|
||||
|
||||
const defaultOption = (options.find((opt) => opt.default) || options[0]) as
|
||||
| TWUISelectOptionObject<KeyType, T>
|
||||
| undefined;
|
||||
|
||||
const [value, setValue] = React.useState<
|
||||
TWUISelectOptionObject<KeyType, T> | undefined
|
||||
>(
|
||||
defaultOption
|
||||
? {
|
||||
value: defaultOption?.value,
|
||||
data: defaultOption?.data,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState<string>(
|
||||
defaultOption?.value || ""
|
||||
);
|
||||
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
|
||||
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const isFocusedRef = React.useRef<boolean>(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const contentWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
let focusTimeout: any;
|
||||
let keyDownInterval: any;
|
||||
const FOCUS_TIMEOUT = 200;
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const currentSelectValue = selectRef.current?.value;
|
||||
|
||||
if (currentSelectValue && validateValueFn) {
|
||||
validateValueFn(currentSelectValue).then((res) => {
|
||||
setValidity(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentOptions(options);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
dispatchState?.(value.data);
|
||||
setInputValue(value.value);
|
||||
changeHandler?.(value.value);
|
||||
}
|
||||
|
||||
clearTimeout(focusTimeout);
|
||||
setOpen(false);
|
||||
setSelectIndex(undefined);
|
||||
}, [value]);
|
||||
|
||||
const handleArrowUpScrollAdjust = React.useCallback(() => {
|
||||
if (contentWrapperRef.current) {
|
||||
const targetOption = contentWrapperRef.current.querySelector(
|
||||
".twui-select-target-option"
|
||||
) as HTMLButtonElement;
|
||||
|
||||
if (targetOption) {
|
||||
contentWrapperRef.current.scrollTop =
|
||||
targetOption.offsetTop - 100;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleArrowDownScrollAdjust = React.useCallback(() => {
|
||||
if (contentWrapperRef.current) {
|
||||
const targetOption = contentWrapperRef.current.querySelector(
|
||||
".twui-select-target-option"
|
||||
) as HTMLButtonElement;
|
||||
|
||||
if (targetOption) {
|
||||
contentWrapperRef.current.scrollTop =
|
||||
targetOption.offsetTop -
|
||||
(contentWrapperRef.current.offsetHeight - 100);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectIndex) return;
|
||||
}, [selectIndex]);
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
if (selectIndex !== undefined) {
|
||||
setValue(currentOptions[selectIndex]);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (selectIndex == undefined) {
|
||||
setSelectIndex(currentOptions.length - 1);
|
||||
} else if (selectIndex === 0) {
|
||||
setSelectIndex(0);
|
||||
} else {
|
||||
setSelectIndex(selectIndex - 1);
|
||||
}
|
||||
handleArrowUpScrollAdjust();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (selectIndex == undefined) {
|
||||
setSelectIndex(0);
|
||||
} else if (selectIndex === currentOptions.length - 1) {
|
||||
setSelectIndex(currentOptions.length - 1);
|
||||
} else {
|
||||
setSelectIndex(selectIndex + 1);
|
||||
}
|
||||
handleArrowDownScrollAdjust();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
clearInterval(keyDownInterval);
|
||||
|
||||
handleKey(e);
|
||||
};
|
||||
|
||||
// const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// e.preventDefault();
|
||||
|
||||
// keyDownInterval = setInterval(() => {
|
||||
// handleKey(e);
|
||||
// }, 100);
|
||||
// };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge(
|
||||
"gap-1 w-full",
|
||||
"twui-search-select-wrapper",
|
||||
wrapperWrapperProps?.className
|
||||
)}
|
||||
onKeyUp={handleKeyUp}
|
||||
// onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Dropdown
|
||||
disableClickActions
|
||||
target={
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={props.title || "Search Options"}
|
||||
value={inputValue}
|
||||
prefix={(<Search size={18} />) as any}
|
||||
suffix={(<ChevronDown size={20} />) as any}
|
||||
suffixProps={{
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(focusTimeout);
|
||||
setOpen(!open);
|
||||
},
|
||||
className: "pointer-events-auto opacity-100",
|
||||
}}
|
||||
changeHandler={(value) => {
|
||||
if (!isFocusedRef.current) return;
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
clearTimeout(focusTimeout);
|
||||
isFocusedRef.current = true;
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
focusTimeout = setTimeout(() => {
|
||||
isFocusedRef.current = false;
|
||||
setOpen(false);
|
||||
}, FOCUS_TIMEOUT);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (!open) setOpen(true);
|
||||
setInputValue(e.target.value);
|
||||
const updatedOptions = options.filter((option) =>
|
||||
option.value
|
||||
.toLowerCase()
|
||||
.match(
|
||||
new RegExp(
|
||||
`${e.target.value.toLowerCase()}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (updatedOptions?.[0]) {
|
||||
setCurrentOptions(updatedOptions);
|
||||
} else {
|
||||
setCurrentOptions(options);
|
||||
}
|
||||
|
||||
setSelectIndex(undefined);
|
||||
}}
|
||||
componentRef={inputRef}
|
||||
showLabel={showLabel}
|
||||
/>
|
||||
}
|
||||
targetWrapperProps={{ className: "w-full" }}
|
||||
contentWrapperProps={{ className: "w-full" }}
|
||||
className="w-full"
|
||||
externalOpen={currentOptions?.[0] && open}
|
||||
>
|
||||
<Paper
|
||||
className={twMerge(
|
||||
"gap-0 p-0 w-full max-h-[40vh] overflow-y-auto"
|
||||
)}
|
||||
componentRef={contentWrapperRef}
|
||||
>
|
||||
<Stack className="w-full items-start gap-0">
|
||||
{currentOptions?.[0]
|
||||
? currentOptions.map((_o, index) => {
|
||||
const isTargetOption = index === selectIndex;
|
||||
const targetOptionClasses = twMerge(
|
||||
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
|
||||
"twui-select-target-option"
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Button
|
||||
title={_o.title || "Option"}
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(_o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={twMerge(
|
||||
"w-full text-foreground-light dark:text-foreground-dark",
|
||||
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
|
||||
isTargetOption
|
||||
? targetOptionClasses
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{_o.value}
|
||||
</Button>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { ChevronDown, Info, LucideProps } from "lucide-react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
Dispatch,
|
||||
InputHTMLAttributes,
|
||||
LabelHTMLAttributes,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SelectHTMLAttributes,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Stack from "../layout/Stack";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
|
||||
|
||||
export type TWUISelectValidityObject = {
|
||||
isValid?: boolean;
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
export type TWUISelectOptionObject<
|
||||
KeyType extends string = string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
> = {
|
||||
title?: string;
|
||||
value: KeyType;
|
||||
default?: boolean;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type TWUISelectProps<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
> = DetailedHTMLProps<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
> & {
|
||||
options: TWUISelectOptionObject<KeyType, T>[];
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
componentRef?: RefObject<HTMLSelectElement>;
|
||||
iconProps?: LucideProps;
|
||||
changeHandler?: (value: KeyType, data?: T) => void;
|
||||
info?: string | ReactNode;
|
||||
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
|
||||
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
|
||||
name?: KeyType;
|
||||
};
|
||||
|
||||
export type TWUISelectValueObject<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
> = {
|
||||
value: KeyType;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Select Element
|
||||
* @className twui-select-wrapper
|
||||
* @className twui-select
|
||||
* @className twui-select-dropdown-icon
|
||||
*/
|
||||
export default function Select<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
>({
|
||||
label,
|
||||
options,
|
||||
componentRef,
|
||||
labelProps,
|
||||
wrapperProps,
|
||||
showLabel,
|
||||
iconProps,
|
||||
changeHandler,
|
||||
info,
|
||||
validateValueFn,
|
||||
wrapperWrapperProps,
|
||||
dispatchState,
|
||||
...props
|
||||
}: TWUISelectProps<KeyType, T>) {
|
||||
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
|
||||
|
||||
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
|
||||
{
|
||||
value: options[0]?.value,
|
||||
data: options[0]?.data,
|
||||
}
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const currentSelectValue = selectRef.current?.value;
|
||||
|
||||
if (currentSelectValue && validateValueFn) {
|
||||
validateValueFn(currentSelectValue).then((res) => {
|
||||
setValidity(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatchState?.(value.data);
|
||||
}, [value]);
|
||||
|
||||
const selectID = label
|
||||
? twuiSlugify(label)
|
||||
: props.name
|
||||
? twuiSlugify(props.name)
|
||||
: props.title
|
||||
? twuiSlugify(props.title)
|
||||
: `select-${Math.round(Math.random() * 1000000)}`;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge("gap-1", wrapperWrapperProps?.className)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"relative w-full flex items-center border rounded-default",
|
||||
"border-slate-300 dark:border-white/20 pr-2",
|
||||
"focus:border-slate-700 dark:focus:border-white/50",
|
||||
"outline-slate-300 dark:outline-white/20",
|
||||
"focus:outline-slate-700 dark:focus:outline-white/50",
|
||||
"bg-white dark:bg-background-dark",
|
||||
validity.isValid ? "" : "outline-warning border-warning",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{showLabel && (
|
||||
<label
|
||||
htmlFor={selectID}
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
|
||||
"dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
|
||||
"twui-input-label",
|
||||
labelProps?.className
|
||||
)}
|
||||
>
|
||||
{label || props.title || props.name}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<select
|
||||
id={selectID}
|
||||
aria-label={props["aria-label"] || props.title}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
|
||||
"grow !border-none !outline-none",
|
||||
"twui-select",
|
||||
props.className
|
||||
)}
|
||||
ref={selectRef}
|
||||
value={
|
||||
options.flat().find((opt) => opt.default)?.value ||
|
||||
undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
const targetValue = options.find(
|
||||
(opt) => opt.value == e.target.value
|
||||
);
|
||||
|
||||
if (targetValue) {
|
||||
setValue(targetValue);
|
||||
}
|
||||
|
||||
changeHandler?.(
|
||||
e.target.value as (typeof options)[number]["value"],
|
||||
targetValue?.data
|
||||
);
|
||||
|
||||
props.onChange?.(e);
|
||||
|
||||
validateValueFn?.(e.target.value).then((res) => {
|
||||
setValidity(res);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{options.flat().map((option, index) => {
|
||||
const optionTitle =
|
||||
option.title || twuiSlugToNormalText(option.value);
|
||||
|
||||
return (
|
||||
<option key={index} value={option.value}>
|
||||
{optionTitle}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
|
||||
<ChevronDown
|
||||
size={20}
|
||||
{...iconProps}
|
||||
className={twMerge(
|
||||
"pointer-events-none -ml-6",
|
||||
iconProps?.className
|
||||
)}
|
||||
/>
|
||||
|
||||
{info && (
|
||||
<Dropdown
|
||||
target={
|
||||
<div title="Select Info Button">
|
||||
<Info size={20} />
|
||||
</div>
|
||||
}
|
||||
hoverOpen
|
||||
>
|
||||
<Card className="min-w-[250px] p-6">
|
||||
{typeof info == "string" ? (
|
||||
<Span>{info}</Span>
|
||||
) : (
|
||||
info
|
||||
)}
|
||||
</Card>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
{!validity.isValid && validity.msg ? (
|
||||
<Span size="smaller" className="text-warning">
|
||||
{validity.msg}
|
||||
</Span>
|
||||
) : undefined}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Input, { InputProps } from "./Input";
|
||||
|
||||
/**
|
||||
* # Textarea Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Textarea<KeyType extends string>({
|
||||
componentRef,
|
||||
...props
|
||||
}: InputProps<KeyType>) {
|
||||
return <Input istextarea {...props} componentRef={componentRef} />;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
type Param = {
|
||||
/**
|
||||
* Custom Event Name
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Dispatch Custom Event
|
||||
*/
|
||||
export default function useCustomEventDispatch<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ name }: Param) {
|
||||
const dispatchCustomEvent = React.useCallback((value: T | string) => {
|
||||
let dataParsed = typeof value == "object" ? value : undefined;
|
||||
const str = typeof value == "string" ? value : undefined;
|
||||
|
||||
if (str) {
|
||||
try {
|
||||
dataParsed = JSON.parse(str);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const event = new CustomEvent(name, {
|
||||
detail: {
|
||||
data: dataParsed,
|
||||
message: str,
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
return { dispatchCustomEvent };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
type Param = {
|
||||
/**
|
||||
* Custom Event Name
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Listen For Custom Event
|
||||
*/
|
||||
export default function useCustomEventListener<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ name }: Param) {
|
||||
const [data, setData] = React.useState<T | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const dataEventListenerCallback = React.useCallback((e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const eventPayloadMessage = customEvent.detail.message as
|
||||
| string
|
||||
| undefined;
|
||||
const eventPayloadData = customEvent.detail.data as T | undefined;
|
||||
|
||||
if (eventPayloadMessage) setMessage(eventPayloadMessage);
|
||||
if (eventPayloadData) setData(eventPayloadData);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(name, dataEventListenerCallback, false);
|
||||
|
||||
return function () {
|
||||
window.removeEventListener(name, dataEventListenerCallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, message };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useRef } from "react";
|
||||
|
||||
type Param = {
|
||||
elementRef?: React.RefObject<Element | undefined>;
|
||||
className?: string;
|
||||
elId?: string;
|
||||
options?: IntersectionObserverInit;
|
||||
removeIntersected?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export default function useIntersectionObserver({
|
||||
elementRef,
|
||||
className,
|
||||
options,
|
||||
removeIntersected,
|
||||
delay,
|
||||
elId,
|
||||
}: Param) {
|
||||
let timeoutRef = useRef<any>(null);
|
||||
|
||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
|
||||
const observerTriggerDelay = delay || 200;
|
||||
|
||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||
(entries, observer) => {
|
||||
const entry = entries[0];
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsIntersecting(true);
|
||||
|
||||
if (removeIntersected) {
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}, observerTriggerDelay);
|
||||
} else {
|
||||
setIsIntersecting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = elId
|
||||
? document.getElementById(elId)
|
||||
: elementRef?.current;
|
||||
const elements = className
|
||||
? document.querySelectorAll(`.${className}`)
|
||||
: null;
|
||||
|
||||
if (!element && !className && refresh < 5) {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
setRefresh(refresh + 1);
|
||||
}, 2000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, {
|
||||
rootMargin: "0px 0px 0px 0px",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (elements) {
|
||||
elements.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
} else if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return { isIntersecting };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
export type UseLocalStorageParam<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
> = {
|
||||
key: keyof T;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Use Local Storage
|
||||
*/
|
||||
export default function useLocalStorage<
|
||||
T extends Record<string, any> | undefined = undefined
|
||||
>(param?: UseLocalStorageParam) {
|
||||
const [data, setData] =
|
||||
React.useState<T extends undefined ? string | null : T>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (param?.key) {
|
||||
const value = localStorage.getItem(param.key as string);
|
||||
try {
|
||||
const jsonValue = JSON.parse(value || "");
|
||||
setData(jsonValue as any);
|
||||
} catch (error) {
|
||||
setData(value as any);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { data };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
type Params = {
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
export default function useReady(params?: Params) {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
const finalTimeout = params?.timeout || 300;
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setReady(true);
|
||||
}, finalTimeout);
|
||||
});
|
||||
|
||||
return function () {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ready };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
type Params = {
|
||||
initialLoading?: boolean;
|
||||
initialReady?: boolean;
|
||||
initialOpen?: boolean;
|
||||
};
|
||||
|
||||
export type UseStatusStatusType = {
|
||||
msg?: string;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
export default function useStatus(params?: Params) {
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(
|
||||
params?.initialLoading || false,
|
||||
);
|
||||
const [status, setStatus] = React.useState<UseStatusStatusType>({});
|
||||
const [ready, setReady] = React.useState(params?.initialReady || false);
|
||||
const [open, setOpen] = React.useState(params?.initialOpen || false);
|
||||
|
||||
return {
|
||||
refresh,
|
||||
setRefresh,
|
||||
loading,
|
||||
setLoading,
|
||||
status,
|
||||
setStatus,
|
||||
ready,
|
||||
setReady,
|
||||
open,
|
||||
setOpen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useRef } from "react";
|
||||
|
||||
export type UseWebsocketHookParams = {
|
||||
debounce?: number;
|
||||
url: string;
|
||||
disableReconnect?: boolean;
|
||||
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
|
||||
keepAliveDuration?: number;
|
||||
/** Interval in ms to force-refresh the connection */
|
||||
refreshConnection?: number;
|
||||
};
|
||||
|
||||
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
||||
|
||||
/**
|
||||
* # Use Websocket Hook
|
||||
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
|
||||
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
||||
*
|
||||
* @example window.addEventListener("wsDataEvent", (e)=>{
|
||||
* console.log(e.detail.data) // type object
|
||||
* })
|
||||
* @example window.addEventListener("wsMessageEvent", (e)=>{
|
||||
* console.log(e.detail.message) // type string
|
||||
* })
|
||||
*/
|
||||
export default function useWebSocket<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({
|
||||
url,
|
||||
debounce,
|
||||
disableReconnect,
|
||||
keepAliveDuration,
|
||||
refreshConnection,
|
||||
}: UseWebsocketHookParams) {
|
||||
const DEBOUNCE = debounce || 500;
|
||||
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
|
||||
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||
|
||||
const tries = useRef(0);
|
||||
|
||||
// Refs to avoid stale closures in callbacks
|
||||
const urlRef = useRef(url);
|
||||
const disableReconnectRef = useRef(disableReconnect);
|
||||
const keepAliveDurationRef = useRef(KEEP_ALIVE_DURATION);
|
||||
|
||||
React.useEffect(() => {
|
||||
urlRef.current = url;
|
||||
disableReconnectRef.current = disableReconnect;
|
||||
keepAliveDurationRef.current = KEEP_ALIVE_DURATION;
|
||||
});
|
||||
|
||||
const msgInterval = useRef<any>(null);
|
||||
const sendInterval = useRef<any>(null);
|
||||
const keepAliveInterval = useRef<any>(null);
|
||||
const refreshInterval = useRef<any>(null);
|
||||
const reconnectTimeout = useRef<any>(null);
|
||||
|
||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(undefined);
|
||||
const socketRef = useRef<WebSocket | undefined>(undefined);
|
||||
|
||||
const messageQueueRef = React.useRef<string[]>([]);
|
||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* # Dispatch Custom Event
|
||||
*/
|
||||
const dispatchCustomEvent = React.useCallback(
|
||||
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
||||
const event = new CustomEvent(evtName, {
|
||||
detail: {
|
||||
data: value,
|
||||
message: value,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* # Connect to Websocket
|
||||
*/
|
||||
const connect = React.useCallback(() => {
|
||||
const currentUrl = urlRef.current;
|
||||
const domain = window.location.origin;
|
||||
const wsURL = currentUrl.startsWith("ws")
|
||||
? currentUrl
|
||||
: domain.replace(/^http/, "ws") + ("/" + currentUrl).replace(/\/\//g, "/");
|
||||
|
||||
if (!wsURL) return;
|
||||
|
||||
const ws = new WebSocket(wsURL);
|
||||
|
||||
ws.onerror = () => {
|
||||
console.log(`Websocket ERROR:`);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
messageQueueRef.current.push(ev.data);
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
|
||||
keepAliveInterval.current = window.setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(KEEP_ALIVE_MESSAGE);
|
||||
}
|
||||
}, keepAliveDurationRef.current);
|
||||
|
||||
tries.current = 0;
|
||||
socketRef.current = ws;
|
||||
setSocket(ws);
|
||||
console.log(`Websocket connected to ${wsURL}`);
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
console.log("Websocket closed!", {
|
||||
code: ev.code,
|
||||
reason: ev.reason,
|
||||
wasClean: ev.wasClean,
|
||||
});
|
||||
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
socketRef.current = undefined;
|
||||
setSocket(undefined);
|
||||
|
||||
if (disableReconnectRef.current) return;
|
||||
|
||||
if (tries.current >= 3) {
|
||||
console.log("Max reconnect attempts reached.");
|
||||
return;
|
||||
}
|
||||
|
||||
tries.current += 1;
|
||||
const backoff = Math.min(1000 * 2 ** tries.current, 30000);
|
||||
console.log(`Attempting to reconnect in ${backoff}ms... (attempt ${tries.current})`);
|
||||
reconnectTimeout.current = window.setTimeout(connect, backoff);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Initial Connection
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(reconnectTimeout.current);
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
window.clearInterval(refreshInterval.current);
|
||||
socketRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Refresh Connection Interval
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!refreshConnection) return;
|
||||
|
||||
refreshInterval.current = window.setInterval(() => {
|
||||
console.log("Refreshing WebSocket connection...");
|
||||
window.clearTimeout(reconnectTimeout.current);
|
||||
socketRef.current?.close();
|
||||
tries.current = 0;
|
||||
connect();
|
||||
}, refreshConnection);
|
||||
|
||||
return () => window.clearInterval(refreshInterval.current);
|
||||
}, [refreshConnection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
|
||||
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(sendInterval.current);
|
||||
window.clearInterval(msgInterval.current);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Received Message Queue Handler
|
||||
*/
|
||||
const handleReceivedMessageQueue = React.useCallback(() => {
|
||||
try {
|
||||
const msg = messageQueueRef.current.shift();
|
||||
|
||||
if (!msg) return;
|
||||
|
||||
const jsonData = JSON.parse(msg);
|
||||
dispatchCustomEvent("wsMessageEvent", msg);
|
||||
dispatchCustomEvent("wsDataEvent", jsonData);
|
||||
} catch (error) {
|
||||
console.log("Unable to parse string. Returning string.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Send Message Queue Handler
|
||||
*/
|
||||
const handleSendMessageQueue = React.useCallback(() => {
|
||||
const ws = socketRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
window.clearInterval(sendInterval.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessage = sendMessageQueueRef.current.shift();
|
||||
if (!newMessage) return;
|
||||
|
||||
ws.send(newMessage);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Send Data Function
|
||||
*/
|
||||
const sendData = React.useCallback((data: T) => {
|
||||
try {
|
||||
const queueItemJSON = JSON.stringify(data);
|
||||
|
||||
const existingQueue = sendMessageQueueRef.current.find(
|
||||
(q) => q === queueItemJSON
|
||||
);
|
||||
if (!existingQueue) {
|
||||
sendMessageQueueRef.current.push(queueItemJSON);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("Error Sending socket message", error.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { socket, sendData };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { WebSocketEventNames } from "./useWebSocket";
|
||||
|
||||
type Param = {
|
||||
listener?: (typeof WebSocketEventNames)[number];
|
||||
};
|
||||
|
||||
/**
|
||||
* # Use Websocket Data Event Handler Hook
|
||||
*/
|
||||
export default function useWebSocketEventHandler<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>(param?: Param) {
|
||||
const [data, setData] = React.useState<T | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState<string | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const dataEventListenerCallback = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const data = customEvent.detail.data as T | undefined;
|
||||
const __msg = customEvent.detail.message as string | undefined;
|
||||
|
||||
if (data) setData(data);
|
||||
if (__msg && typeof __msg == "string") setMessage(__msg);
|
||||
};
|
||||
|
||||
const messageEventName: (typeof WebSocketEventNames)[number] =
|
||||
param?.listener || "wsDataEvent";
|
||||
window.addEventListener(messageEventName, dataEventListenerCallback);
|
||||
|
||||
return function () {
|
||||
window.removeEventListener(
|
||||
messageEventName,
|
||||
dataEventListenerCallback
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, message };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useWindowFocus() {
|
||||
const [isWindowFocused, setIsWindowFocused] = useState(false);
|
||||
|
||||
const windowFocusCb = useCallback(() => {
|
||||
setIsWindowFocused(true);
|
||||
}, []);
|
||||
|
||||
const windowBlurCb = useCallback(() => {
|
||||
setIsWindowFocused(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("focus", windowFocusCb);
|
||||
window.addEventListener("blur", windowBlurCb);
|
||||
return function () {
|
||||
window.removeEventListener("focus", windowFocusCb);
|
||||
window.removeEventListener("blur", windowBlurCb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isWindowFocused };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
|
||||
import Link from "./Link";
|
||||
import Row from "./Row";
|
||||
|
||||
type Props = ComponentProps<typeof Link> & {
|
||||
link: TWUI_LINK_LIST_LINK_OBJECT;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Link With an Arrow
|
||||
* @className twui-arrowed-link
|
||||
*/
|
||||
export default function ArrowedLink({ link, icon, ...props }: Props) {
|
||||
return (
|
||||
<Link href={link.url} {...props} {...link.linkProps}>
|
||||
<Row>
|
||||
<span>{link.title}</span>
|
||||
{icon || <ArrowUpRight size={17} />}
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributeAnchorTarget,
|
||||
HTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Loading from "../elements/Loading";
|
||||
import LucideIcon, { TWUILucideIconName } from "../elements/lucide-icon";
|
||||
|
||||
export type TWUIButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
title: string;
|
||||
variant?: "normal" | "ghost" | "outlined";
|
||||
color?:
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "text"
|
||||
| "white"
|
||||
| "accent"
|
||||
| "gray"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "success";
|
||||
size?: "small" | "smaller" | "normal" | "large" | "larger";
|
||||
loadingIconSize?: React.ComponentProps<typeof Loading>["size"];
|
||||
href?: string;
|
||||
target?: HTMLAttributeAnchorTarget;
|
||||
loading?: boolean;
|
||||
linkProps?: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
beforeIcon?: TWUILucideIconName | React.JSX.Element;
|
||||
afterIcon?: TWUILucideIconName | React.JSX.Element;
|
||||
buttonContentProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
loadingProps?: ComponentProps<typeof Loading>;
|
||||
};
|
||||
|
||||
const buttonClasses: Record<string, Record<string, string>> = {
|
||||
normal: {
|
||||
primary: twMerge(
|
||||
"bg-primary hover:bg-primary-hover text-white",
|
||||
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover",
|
||||
"twui-button-primary",
|
||||
),
|
||||
secondary: twMerge(
|
||||
"bg-secondary hover:bg-secondary-hover text-white",
|
||||
"twui-button-secondary",
|
||||
),
|
||||
white: twMerge(
|
||||
"!bg-white hover:!bg-slate-200 !text-slate-800",
|
||||
"twui-button-white",
|
||||
),
|
||||
accent: twMerge(
|
||||
"bg-accent hover:bg-accent-hover text-white",
|
||||
"twui-button-accent",
|
||||
),
|
||||
gray: twMerge(
|
||||
"bg-gray hover:bg-gray-hover text-foreground-light",
|
||||
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
|
||||
"twui-button-gray",
|
||||
),
|
||||
success: twMerge(
|
||||
"bg-success hover:bg-success-hover text-white",
|
||||
"dark:bg-success hover:dark:bg-success-hover",
|
||||
"twui-button-success",
|
||||
),
|
||||
error: twMerge(
|
||||
"bg-error hover:bg-error-hover text-white",
|
||||
"dark:bg-error hover:dark:bg-error-hover",
|
||||
"twui-button-error",
|
||||
),
|
||||
},
|
||||
outlined: {
|
||||
primary: twMerge(
|
||||
"bg-transparent outline outline-1 outline-primary",
|
||||
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
|
||||
"twui-button-primary-outlined",
|
||||
),
|
||||
secondary: twMerge(
|
||||
"bg-transparent outline outline-1 outline-secondary",
|
||||
"text-secondary",
|
||||
"twui-button-secondary-outlined",
|
||||
),
|
||||
accent: twMerge(
|
||||
"bg-transparent outline outline-1 outline-accent",
|
||||
"text-accent",
|
||||
"twui-button-accent-outlined",
|
||||
),
|
||||
gray: twMerge(
|
||||
"bg-transparent outline outline-1 outline-slate-300",
|
||||
"text-slate-600 dark:text-white/60 dark:outline-white/30",
|
||||
"twui-button-gray-outlined",
|
||||
),
|
||||
white: twMerge(
|
||||
"bg-transparent outline outline-1 outline-white/50",
|
||||
"text-white",
|
||||
"twui-button-white-outlined",
|
||||
),
|
||||
error: twMerge(
|
||||
"bg-transparent outline outline-1 outline-error text-error",
|
||||
"dark:outline-error dark:text-error-dark",
|
||||
"twui-button-error-outlined",
|
||||
),
|
||||
},
|
||||
ghost: {
|
||||
primary: twMerge(
|
||||
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||
"text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
|
||||
"twui-button-primary-ghost",
|
||||
),
|
||||
secondary: twMerge(
|
||||
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||
"text-secondary hover:bg-transparent dark:hover:bg-transparent",
|
||||
"twui-button-secondary-ghost",
|
||||
),
|
||||
text: twMerge(
|
||||
"bg-transparent dark:bg-transparent outline-none p-2 dark:text-foreground-dark",
|
||||
"text-foreground-light hover:bg-transparent dark:hover:bg-transparent",
|
||||
"twui-button-secondary-ghost",
|
||||
),
|
||||
accent: twMerge(
|
||||
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||
"text-accent hover:bg-transparent dark:hover:bg-transparent",
|
||||
"twui-button-accent-ghost",
|
||||
),
|
||||
gray: twMerge(
|
||||
"bg-transparent dark:bg-transparent outline-none p-2 hover:bg-transparent dark:hover:bg-transparent",
|
||||
"text-slate-600 dark:text-white/70 hover:opacity-80",
|
||||
"twui-button-gray-ghost",
|
||||
),
|
||||
error: twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-red-600 dark:text-red-400",
|
||||
"twui-button-error-ghost",
|
||||
),
|
||||
warning: twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-yellow-600",
|
||||
"twui-button-warning-ghost",
|
||||
),
|
||||
success: twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-success",
|
||||
"twui-button-success-ghost",
|
||||
),
|
||||
white: twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-white",
|
||||
"twui-button-white-ghost",
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* # Buttons
|
||||
* @className twui-button-general
|
||||
* @className twui-button-content-wrapper
|
||||
* @className twui-button-primary
|
||||
* @className twui-button-primary-outlined
|
||||
* @className twui-button-primary-ghost
|
||||
* @className twui-button-secondary
|
||||
* @className twui-button-secondary-outlined
|
||||
* @className twui-button-secondary-ghost
|
||||
* @className twui-button-white
|
||||
* @className twui-button-white-outlined
|
||||
* @className twui-button-white-ghost
|
||||
* @className twui-button-accent
|
||||
* @className twui-button-accent-outlined
|
||||
* @className twui-button-accent-ghost
|
||||
* @className twui-button-gray
|
||||
* @className twui-button-gray-outlined
|
||||
* @className twui-button-gray-ghost
|
||||
*
|
||||
* @example
|
||||
```css
|
||||
CSS directive:
|
||||
|
||||
//@theme inline {
|
||||
--breakpoint-xs: 350px;
|
||||
--color-primary: #000000;
|
||||
--color-primary-hover: #29292b;
|
||||
--color-primary-outline: #29292b;
|
||||
--color-primary-text: #29292b;
|
||||
--color-primary-dark: #29292b;
|
||||
--color-primary-dark-hover: #4b4b4b;
|
||||
--color-primary-dark-outline: #4b4b4b;
|
||||
--color-primary-dark-text: #4b4b4b;
|
||||
--color-secondary: #000000;
|
||||
--color-secondary-hover: #dddddd;
|
||||
--color-secondary-outline: #dddddd;
|
||||
--color-secondary-text: #dddddd;
|
||||
--color-secondary-dark: #000000;
|
||||
--color-secondary-dark-hover: #dddddd;
|
||||
--color-secondary-dark-outline: #dddddd;
|
||||
--color-secondary-dark-text: #dddddd;
|
||||
--color-accent: #000000;
|
||||
--color-accent-hover: #dddddd;
|
||||
--color-accent-outline: #dddddd;
|
||||
--color-accent-text: #dddddd;
|
||||
--color-accent-dark: #000000;
|
||||
--color-accent-dark-hover: #dddddd;
|
||||
--color-accent-dark-outline: #dddddd;
|
||||
--color-accent-dark-text: #dddddd;
|
||||
}
|
||||
```
|
||||
*/
|
||||
export default function Button({
|
||||
href,
|
||||
target,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
buttonContentProps,
|
||||
linkProps,
|
||||
beforeIcon,
|
||||
afterIcon,
|
||||
loading,
|
||||
loadingIconSize,
|
||||
loadingProps,
|
||||
...props
|
||||
}: TWUIButtonProps) {
|
||||
const finalClassName = buttonClasses[variant || "normal"]?.[color || "primary"] ?? "";
|
||||
|
||||
const buttonComponent = (
|
||||
<button
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"bg-primary text-white font-medium px-4 py-2 rounded-default",
|
||||
"flex items-center justify-center relative transition-all cursor-pointer",
|
||||
props.disabled ? "opacity-40 cursor-not-allowed" : "",
|
||||
"twui-button-general",
|
||||
size == "small"
|
||||
? "px-3 py-1.5 twui-button-small text-sm"
|
||||
: size == "smaller"
|
||||
? "px-2 py-1 text-xs twui-button-smaller"
|
||||
: size == "large"
|
||||
? "text-lg twui-button-large"
|
||||
: size == "larger"
|
||||
? "px-5 py-3 text-xl twui-button-larger"
|
||||
: "twui-button-base",
|
||||
finalClassName,
|
||||
loading ? "pointer-events-none opacity-80" : "",
|
||||
props.className,
|
||||
)}
|
||||
aria-label={props.title}
|
||||
>
|
||||
<div
|
||||
{...buttonContentProps}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center gap-2 whitespace-nowrap",
|
||||
loading ? "opacity-0" : "",
|
||||
"twui-button-content-wrapper",
|
||||
buttonContentProps?.className,
|
||||
)}
|
||||
>
|
||||
{beforeIcon ? (
|
||||
typeof beforeIcon == "string" ? (
|
||||
<LucideIcon name={beforeIcon as TWUILucideIconName} />
|
||||
) : (
|
||||
beforeIcon
|
||||
)
|
||||
) : null}
|
||||
{props.children}
|
||||
{afterIcon ? (
|
||||
typeof afterIcon == "string" ? (
|
||||
<LucideIcon name={afterIcon as TWUILucideIconName} />
|
||||
) : (
|
||||
afterIcon
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Loading
|
||||
size={(() => {
|
||||
if (loadingIconSize) return loadingIconSize;
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "small";
|
||||
case "smaller":
|
||||
return "smaller";
|
||||
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
})()}
|
||||
{...loadingProps}
|
||||
className={twMerge("absolute", loadingProps?.className)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (href)
|
||||
return (
|
||||
<a
|
||||
{...linkProps}
|
||||
href={href}
|
||||
target={target}
|
||||
title={props.title}
|
||||
aria-label={props.title}
|
||||
>
|
||||
{buttonComponent}
|
||||
</a>
|
||||
);
|
||||
return buttonComponent;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Flexbox Column
|
||||
* @className twui-center
|
||||
*/
|
||||
export default function Center({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center justify-center gap-4 p-2 w-full",
|
||||
"h-full twui-center",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Default Container
|
||||
* @className twui-container
|
||||
*/
|
||||
export default function Container({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex w-full max-w-container gap-4 justify-between",
|
||||
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
|
||||
"twui-container",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
vertical?: boolean;
|
||||
dashed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Vertical and Horizontal Divider
|
||||
* @className twui-divider
|
||||
* @className twui-divider-horizontal
|
||||
* @className twui-divider-vertical
|
||||
*/
|
||||
export default function Divider({ vertical, dashed, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"border-slate-200 dark:border-white/10",
|
||||
vertical
|
||||
? "border-0 border-l h-full min-h-5"
|
||||
: "border-0 border-t w-full",
|
||||
"twui-divider",
|
||||
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
|
||||
dashed ? "border-dashed" : "border-solid",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Img, { TWUIImageProps } from "./Img";
|
||||
import Border from "../elements/Border";
|
||||
|
||||
export default function DocsImg({ ...props }: TWUIImageProps) {
|
||||
return (
|
||||
<Border className={twMerge("p-0 mb-10")}>
|
||||
<Img
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full h-auto rounded-default overflow-hidden",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
</Border>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # General Footer
|
||||
* @className twui-footer
|
||||
*/
|
||||
export default function Footer({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
|
||||
return (
|
||||
<footer
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center justify-center",
|
||||
"px-4 sm:px-10 py-2",
|
||||
"border-0 border-b border-slate-200 border-solid",
|
||||
"twui-footer",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # H1 Headers
|
||||
* @className twui-h1
|
||||
*/
|
||||
export default function H1({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
return (
|
||||
<h1
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-4xl md:text-5xl mb-4",
|
||||
"twui-headings twui-heading",
|
||||
"twui-h1",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # H2 Headers
|
||||
* @className twui-h2
|
||||
*/
|
||||
export default function H2({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-2xl md:text-3xl mb-4",
|
||||
"twui-headings twui-heading",
|
||||
"twui-h2",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # H3 Headers
|
||||
* @className twui-h3
|
||||
*/
|
||||
export default function H3({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xl mb-4",
|
||||
"twui-headings twui-heading",
|
||||
"twui-h3",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # H4 Headers
|
||||
* @className twui-h4
|
||||
*/
|
||||
export default function H4({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
return (
|
||||
<h4
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-base mb-4",
|
||||
"twui-headings twui-heading",
|
||||
"twui-h4",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # H5 Headers
|
||||
* @className twui-h5
|
||||
*/
|
||||
export default function H5({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
return (
|
||||
<h5
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"mb-4",
|
||||
"twui-headings twui-heading",
|
||||
"twui-h5",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Horizonta Rule (hr)
|
||||
* @className twui-hr
|
||||
*/
|
||||
export default function HR({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>) {
|
||||
return (
|
||||
<hr
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"border-slate-200 dark:border-white/10 w-full my-4",
|
||||
"twui-hr",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Default Header
|
||||
* @className twui-header
|
||||
*/
|
||||
export default function Header({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
|
||||
return (
|
||||
<header
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center justify-center",
|
||||
"px-4 sm:px-10 py-3 flex-wrap",
|
||||
"border-0 border-b border-slate-200 dark:border-white/10 border-solid",
|
||||
"twui-header",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ComponentProps, ReactNode } from "react";
|
||||
import { TWUI_LINK_LIST_LINK_OBJECT } from "../elements/LinkList";
|
||||
import Link from "./Link";
|
||||
import Row from "./Row";
|
||||
|
||||
type Props = ComponentProps<typeof Link> & {
|
||||
link: TWUI_LINK_LIST_LINK_OBJECT;
|
||||
icon: ReactNode;
|
||||
iconPosition?: "before" | "after";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Link With an Icon
|
||||
* @className twui-arrowed-link
|
||||
*/
|
||||
export default function IconLink({
|
||||
link,
|
||||
iconPosition,
|
||||
icon,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={link.url}
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
link.onClick?.(e);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
{iconPosition == "before" || !iconPosition ? icon : null}
|
||||
<span>{link.title}</span>
|
||||
{iconPosition == "after" ? icon : null}
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import _ from "lodash";
|
||||
import React, { DetailedHTMLProps, ImgHTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUIImageProps = DetailedHTMLProps<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
> & {
|
||||
alt: string;
|
||||
size?: number;
|
||||
circle?: boolean;
|
||||
bgImg?: boolean;
|
||||
backgroundImage?: boolean;
|
||||
fallbackImageSrc?: string;
|
||||
srcLight?: string;
|
||||
srcDark?: string;
|
||||
imgErrSrc?: string;
|
||||
imgErrComp?: ReactNode;
|
||||
imgErrSrcLight?: string;
|
||||
imgErrSrcDark?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Image Component
|
||||
* @className twui-img
|
||||
*/
|
||||
export default function Img({
|
||||
imgErrSrc,
|
||||
imgErrComp,
|
||||
imgErrSrcDark,
|
||||
imgErrSrcLight,
|
||||
...props
|
||||
}: TWUIImageProps) {
|
||||
const width = props.size || props.width;
|
||||
const height = props.size || props.height;
|
||||
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
||||
|
||||
const [imageError, setImageError] = React.useState(false);
|
||||
|
||||
const finalProps = _.omit(props, [
|
||||
"size",
|
||||
"circle",
|
||||
"bgImg",
|
||||
"backgroundImage",
|
||||
"fallbackImageSrc",
|
||||
"srcLight",
|
||||
"srcDark",
|
||||
]);
|
||||
|
||||
const interpolatedProps: typeof props = {
|
||||
...finalProps,
|
||||
width: width,
|
||||
height: height,
|
||||
className: twMerge(
|
||||
"object-cover",
|
||||
props.circle && "rounded-full",
|
||||
props.bgImg || props.backgroundImage
|
||||
? "absolute top-0 left-0 w-full h-full object-cover z-0"
|
||||
: "",
|
||||
"twui-img",
|
||||
props.className
|
||||
),
|
||||
onError: (e) => {
|
||||
if (props.fallbackImageSrc) {
|
||||
e.currentTarget.src = props.fallbackImageSrc;
|
||||
}
|
||||
props.onError?.(e);
|
||||
},
|
||||
style: {
|
||||
...(props.size
|
||||
? {
|
||||
width: `${props.size}px`,
|
||||
minWidth: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}
|
||||
: {}),
|
||||
...props.style,
|
||||
},
|
||||
};
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
imgErrComp || (
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
src={
|
||||
imgErrSrc ||
|
||||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (props.srcDark && props.srcLight) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"hidden dark:block",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcDark}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"block dark:hidden",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcLight}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...interpolatedProps}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ArrowUpRight, LucideProps } from "lucide-react";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
> & {
|
||||
showArrow?: boolean;
|
||||
arrowSize?: number;
|
||||
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # General Anchor Elements
|
||||
* @className twui-a | twui-anchor
|
||||
* @info use `cancel-link` class name to prevent triggering this link from a child element
|
||||
*/
|
||||
export default function Link({
|
||||
showArrow,
|
||||
arrowSize = 20,
|
||||
arrowProps,
|
||||
strict,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-link-500 no-underline hover:text-link-500/50",
|
||||
"text-link dark:text-link-dark hover:opacity-80 transition-all",
|
||||
"border-0 border-b border-link dark:border-link-dark border-solid leading-4",
|
||||
"twui-anchor",
|
||||
"twui-a",
|
||||
props.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.closest(".cancel-link")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
props?.onClick?.(e);
|
||||
}}
|
||||
data-strict={strict ? "yes" : undefined}
|
||||
>
|
||||
{props.children}
|
||||
{showArrow && (
|
||||
<ArrowUpRight
|
||||
size={arrowSize}
|
||||
{...arrowProps}
|
||||
className={twMerge(
|
||||
"inline-block ml-1 -mt-[1px]",
|
||||
arrowProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
AnchorHTMLAttributes,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
OlHTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Ordered and unorder Lists
|
||||
* @className twui-ol
|
||||
* @className twui-ul
|
||||
*/
|
||||
export default function List({
|
||||
ordered,
|
||||
items,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLUListElement>, HTMLUListElement> &
|
||||
DetailedHTMLProps<OlHTMLAttributes<HTMLOListElement>, HTMLOListElement> & {
|
||||
items: {
|
||||
content: React.ReactNode | string;
|
||||
}[];
|
||||
ordered?: boolean;
|
||||
}) {
|
||||
const listContent = (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<li key={index}>{item.content}</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = "flex flex-col items-start gap-4";
|
||||
|
||||
if (ordered) {
|
||||
return (
|
||||
<ol
|
||||
{...props}
|
||||
className={twMerge(className, "twui-ol", props.className)}
|
||||
>
|
||||
{listContent}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...props}
|
||||
className={twMerge(className, "twui-ul", props.className)}
|
||||
>
|
||||
{listContent}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type LoadingRectangleBlockProps = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
|
||||
/**
|
||||
* # A loading Rectangle block
|
||||
* @className twui-loading-rectangle-block
|
||||
* @className twui-loading-block
|
||||
*/
|
||||
export default function LoadingRectangleBlock({
|
||||
...props
|
||||
}: LoadingRectangleBlockProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex items-center w-full h-10 animate-pulse bg-slate-200 rounded",
|
||||
"dark:bg-slate-800",
|
||||
"twui-loading-rectangle-block twui-loading-block",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Main Wrapper
|
||||
* @className twui-h1
|
||||
*/
|
||||
export default function Main({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
|
||||
return (
|
||||
<main
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center w-full",
|
||||
"twui-main",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLHeadingElement>,
|
||||
HTMLHeadingElement
|
||||
> & {
|
||||
noMargin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Paragraph Tag
|
||||
* @className twui-p | twui-paragraph
|
||||
*/
|
||||
export default function P({ noMargin, ...props }: Props) {
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"py-4",
|
||||
noMargin ? "!m-0 p-0" : "",
|
||||
"twui-p",
|
||||
"twui-paragraph",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
noWrap?: boolean;
|
||||
itemsStart?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Flexbox Row
|
||||
* @className twui-row
|
||||
*/
|
||||
export default function Row({ noWrap, itemsStart, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row gap-2",
|
||||
noWrap ? "xl:flex-nowrap" : "flex-wrap",
|
||||
itemsStart ? "items-start" : "items-center",
|
||||
"twui-row",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # General Section
|
||||
* @className twui-section
|
||||
*/
|
||||
export default function Section({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center w-full",
|
||||
"px-4 sm:px-10 py-10",
|
||||
"twui-section",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
horizontal?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Space Component
|
||||
* @className twui-spacer
|
||||
*/
|
||||
export default function Spacer({ horizontal, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"",
|
||||
horizontal ? "w-10" : "w-full h-10",
|
||||
"twui-spacer",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # Span element
|
||||
* @className twui-span
|
||||
*/
|
||||
export default function Span({
|
||||
size,
|
||||
variant,
|
||||
truncate,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement> & {
|
||||
size?: "normal" | "small" | "smaller" | "large" | "larger";
|
||||
variant?: "normal" | "faded";
|
||||
truncate?: { lines?: number; width?: number };
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"",
|
||||
size == "small" && "text-sm",
|
||||
size == "smaller" && "text-xs",
|
||||
size == "large" && "text-lg",
|
||||
size == "larger" && "text-xl",
|
||||
variant == "faded" && "opacity-50",
|
||||
truncate ? `` : ``,
|
||||
"twui-span",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
center?: boolean;
|
||||
gap?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Flexbox Column
|
||||
* @className twui-stack
|
||||
*/
|
||||
export default function Stack({ gap, ...props }: Props) {
|
||||
const finalProps = _.omit(props, "center");
|
||||
return (
|
||||
<div
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start gap-4",
|
||||
props.center && "items-center",
|
||||
gap
|
||||
? typeof gap == "string"
|
||||
? `gap-[${gap}]`
|
||||
: `gap-${gap}`
|
||||
: "",
|
||||
"twui-stack",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
center?: boolean;
|
||||
gap?: number | string;
|
||||
componentRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Flexbox Column
|
||||
* @className twui-stack
|
||||
*/
|
||||
export default function Stack({ gap, componentRef, ...props }: Props) {
|
||||
const finalProps = _.omit(props, "center");
|
||||
return (
|
||||
<div
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start gap-4",
|
||||
props.center && "items-center",
|
||||
gap
|
||||
? typeof gap == "string"
|
||||
? `gap-[${gap}]`
|
||||
: `gap-${gap}`
|
||||
: "",
|
||||
"twui-stack",
|
||||
props.className
|
||||
)}
|
||||
ref={componentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { ComponentProps, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import MarkdownEditorPreviewComponent from "./MarkdownEditorPreviewComponent";
|
||||
import MarkdownEditorComponent from "./MarkdownEditorComponent";
|
||||
import MarkdownEditorSelectorButtons from "./MarkdownEditorSelectorButtons";
|
||||
import Row from "../../layout/Row";
|
||||
import Stack from "../../layout/Stack";
|
||||
import AceEditor from "../../editors/AceEditor";
|
||||
import useStatus from "../../hooks/useStatus";
|
||||
|
||||
type Props = {
|
||||
value?: string;
|
||||
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
defaultSideBySide?: boolean;
|
||||
changeHandler?: (content: string) => void;
|
||||
editorProps?: ComponentProps<typeof AceEditor>;
|
||||
maxHeight?: string;
|
||||
noToggleButtons?: boolean;
|
||||
};
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value: existingValue,
|
||||
setValue: setExistingValue,
|
||||
defaultSideBySide,
|
||||
changeHandler,
|
||||
editorProps,
|
||||
maxHeight: existingMaxHeight,
|
||||
noToggleButtons,
|
||||
}: Props) {
|
||||
const [value, setValue] = React.useState<any>(existingValue || ``);
|
||||
|
||||
const [sideBySide, setSideBySide] = React.useState(
|
||||
defaultSideBySide || false,
|
||||
);
|
||||
const [preview, setPreview] = React.useState(false);
|
||||
const { refresh, setRefresh } = useStatus();
|
||||
const updatingFromExtValueRef = useRef(false);
|
||||
|
||||
const maxHeight = existingMaxHeight || "600px";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updatingFromExtValueRef.current) return;
|
||||
setExistingValue?.(value);
|
||||
changeHandler?.(value);
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!existingValue) return;
|
||||
updatingFromExtValueRef.current = true;
|
||||
setValue(existingValue);
|
||||
setTimeout(() => {
|
||||
updatingFromExtValueRef.current = false;
|
||||
setRefresh((prev) => prev + 1);
|
||||
}, 500);
|
||||
}, [existingValue]);
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch">
|
||||
{!noToggleButtons && (
|
||||
<MarkdownEditorSelectorButtons
|
||||
{...{ preview, setPreview, setSideBySide, sideBySide }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sideBySide ? (
|
||||
<Row
|
||||
className={twMerge(
|
||||
`w-full grid xl:grid-cols-2 gap-6 max-h-[${maxHeight}]`,
|
||||
"overflow-auto",
|
||||
)}
|
||||
>
|
||||
<MarkdownEditorComponent
|
||||
setValue={setValue}
|
||||
value={value}
|
||||
maxHeight={maxHeight}
|
||||
refreshDepArr={[refresh]}
|
||||
{...editorProps}
|
||||
/>
|
||||
<MarkdownEditorPreviewComponent
|
||||
value={value}
|
||||
maxHeight={maxHeight}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<Stack
|
||||
className={twMerge(`w-full max-h-[${maxHeight}] h-full`)}
|
||||
>
|
||||
{preview ? (
|
||||
<MarkdownEditorPreviewComponent
|
||||
value={value}
|
||||
maxHeight={maxHeight}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownEditorComponent
|
||||
setValue={setValue}
|
||||
value={value}
|
||||
maxHeight={maxHeight}
|
||||
refreshDepArr={[refresh]}
|
||||
{...editorProps}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user