Updates
This commit is contained in:
parent
f73b56cdc4
commit
db26e26495
75
components/(functions)/popver/grab-popover-styles.ts
Normal file
75
components/(functions)/popver/grab-popover-styles.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
65
components/(partials)/ModalComponent.tsx
Normal file
65
components/(partials)/ModalComponent.tsx
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
115
components/(partials)/PopoverComponent.tsx
Normal file
115
components/(partials)/PopoverComponent.tsx
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
17
components/Readme.md
Normal file
17
components/Readme.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# 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.
|
||||||
173
components/base.css
Normal file
173
components/base.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
193
components/bun.lock
Normal file
193
components/bun.lock
Normal file
@ -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/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
|
||||||
|
|
||||||
|
"@bedrock-layout/use-forwarded-ref": ["@bedrock-layout/use-forwarded-ref@1.6.1", "", { "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/use-stateful-ref@1.4.1", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18" } }, "sha512-4eKO2KdQEXcR5LI4QcxqlJykJUDQJWDeWYAukIn6sRQYoabcfI5kDl61PUi6FR6o8VFgQ8IEP7HleKqWlSe8SQ=="],
|
||||||
|
|
||||||
|
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.2.2", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="],
|
||||||
|
|
||||||
|
"@emotion/memoize": ["@emotion/memoize@0.8.1", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="],
|
||||||
|
|
||||||
|
"@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="],
|
||||||
|
|
||||||
|
"@next/mdx": ["@next/mdx@15.3.2", "", { "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/ace@0.0.52", "", {}, "sha512-YPF9S7fzpuyrxru+sG/rrTpZkC6gpHBPF14W3x70kqVOD+ks6jkYLapk4yceh36xej7K4HYxcyz9ZDQ2lTvwgQ=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
||||||
|
|
||||||
|
"@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||||
|
|
||||||
|
"@types/lodash": ["@types/lodash@4.17.16", "", {}, "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g=="],
|
||||||
|
|
||||||
|
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.15.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
|
||||||
|
|
||||||
|
"@types/stylis": ["@types/stylis@4.2.5", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||||
|
|
||||||
|
"@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="],
|
||||||
|
|
||||||
|
"body-scroll-lock": ["body-scroll-lock@3.1.5", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
|
||||||
|
|
||||||
|
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
|
||||||
|
|
||||||
|
"character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
|
||||||
|
|
||||||
|
"character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
|
||||||
|
|
||||||
|
"character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
|
||||||
|
|
||||||
|
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||||
|
|
||||||
|
"comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="],
|
||||||
|
|
||||||
|
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
|
||||||
|
|
||||||
|
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
|
||||||
|
|
||||||
|
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
|
"hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="],
|
||||||
|
|
||||||
|
"hastscript": ["hastscript@6.0.0", "", { "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": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||||
|
|
||||||
|
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||||
|
|
||||||
|
"html-to-react": ["html-to-react@1.7.0", "", { "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": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||||
|
|
||||||
|
"is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
|
||||||
|
|
||||||
|
"is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
|
||||||
|
|
||||||
|
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
|
||||||
|
|
||||||
|
"is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||||
|
|
||||||
|
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||||
|
|
||||||
|
"lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"parse-entities": ["parse-entities@2.0.0", "", { "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": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||||
|
|
||||||
|
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||||
|
|
||||||
|
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|
||||||
|
"property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="],
|
||||||
|
|
||||||
|
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||||
|
|
||||||
|
"react-code-blocks": ["react-code-blocks@0.1.6", "", { "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": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||||
|
|
||||||
|
"react-responsive-modal": ["react-responsive-modal@6.4.2", "", { "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": ["react-syntax-highlighter@15.6.1", "", { "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": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||||
|
|
||||||
|
"shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
||||||
|
|
||||||
|
"styled-components": ["styled-components@6.1.18", "", { "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": ["stylis@4.3.2", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.3.0", "", {}, "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.7", "", {}, "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
|
||||||
|
|
||||||
|
"styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||||
|
|
||||||
|
"styled-components/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
41
components/composites/docs/TWUIDocsAside.tsx
Normal file
41
components/composites/docs/TWUIDocsAside.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
components/composites/docs/TWUIDocsLink.tsx
Normal file
128
components/composites/docs/TWUIDocsLink.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
components/composites/docs/TWUIDocsRightAside.tsx
Normal file
177
components/composites/docs/TWUIDocsRightAside.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
components/composites/docs/index.tsx
Normal file
88
components/composites/docs/index.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
components/editors/AceEditor.tsx
Normal file
172
components/editors/AceEditor.tsx
Normal file
@ -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>(undefined);
|
||||||
|
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
components/editors/TinyMCE/index.tsx
Normal file
238
components/editors/TinyMCE/index.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import React, { ComponentProps } 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 = passedEditorRef || React.useRef<Editor>(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 =
|
||||||
|
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
|
||||||
|
"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
components/editors/TinyMCE/tinymce.d.ts
vendored
Normal file
3313
components/editors/TinyMCE/tinymce.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
52
components/editors/TinyMCE/useTinyMCE.tsx
Normal file
52
components/editors/TinyMCE/useTinyMCE.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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 =
|
||||||
|
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
125
components/editors/ace-editor-modes.ts
Normal file
125
components/editors/ace-editor-modes.ts
Normal file
@ -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;
|
||||||
42
components/elements/Border.tsx
Normal file
42
components/elements/Border.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
|
||||||
|
componentRef?: RefObject<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Toggle Component
|
||||||
|
* @className_wrapper twui-border
|
||||||
|
*/
|
||||||
|
export default function Border({
|
||||||
|
spacing,
|
||||||
|
componentRef,
|
||||||
|
...props
|
||||||
|
}: TWUI_BORDER_PROPS) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"relative flex items-center gap-2 border border-solid rounded-default",
|
||||||
|
"border-slate-200 dark:border-white/10",
|
||||||
|
spacing
|
||||||
|
? spacing == "normal"
|
||||||
|
? "px-3 py-2"
|
||||||
|
: spacing == "tight"
|
||||||
|
? "px-2 py-1"
|
||||||
|
: ""
|
||||||
|
: "px-3 py-2",
|
||||||
|
"twui-border",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
ref={componentRef}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
components/elements/Breadcrumbs.tsx
Normal file
228
components/elements/Breadcrumbs.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import React, { ComponentProps, ReactNode } from "react";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
import Divider from "../layout/Divider";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import lowerToTitleCase from "../utils/lower-to-title-case";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
|
||||||
|
type LinkObject = {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
excludeRegexMatch?: RegExp;
|
||||||
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
|
currentLinkProps?: ComponentProps<typeof Link>;
|
||||||
|
dividerProps?: ComponentProps<typeof Divider>;
|
||||||
|
backButtonProps?: ComponentProps<typeof Button>;
|
||||||
|
backButton?: boolean;
|
||||||
|
pageUrl?: string;
|
||||||
|
currentTitle?: string;
|
||||||
|
skipHome?: boolean;
|
||||||
|
divider?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # TWUI Breadcrumbs
|
||||||
|
* @className `twui-breadcrumb-link`
|
||||||
|
* @className `twui-current-breadcrumb-wrapper`
|
||||||
|
* @className `twui-breadcrumbs-divider`
|
||||||
|
* @className `twui-breadcrumbs-back-button`
|
||||||
|
*/
|
||||||
|
export default function Breadcrumbs({
|
||||||
|
excludeRegexMatch,
|
||||||
|
linkProps,
|
||||||
|
currentLinkProps,
|
||||||
|
dividerProps,
|
||||||
|
backButton,
|
||||||
|
backButtonProps,
|
||||||
|
pageUrl,
|
||||||
|
currentTitle,
|
||||||
|
skipHome,
|
||||||
|
divider,
|
||||||
|
}: Props) {
|
||||||
|
const [links, setLinks] = React.useState<LinkObject[] | null>(
|
||||||
|
pageUrl
|
||||||
|
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (links) return;
|
||||||
|
|
||||||
|
let pathname = window.location.pathname;
|
||||||
|
|
||||||
|
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
|
||||||
|
url: pathname,
|
||||||
|
excludeRegexMatch,
|
||||||
|
skipHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLinks(validPathLinks);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
setLinks(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!links?.[1]) {
|
||||||
|
return <React.Fragment></React.Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={twMerge(
|
||||||
|
"overflow-x-auto",
|
||||||
|
"twui-current-breadcrumb-wrapper"
|
||||||
|
)}
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{backButton && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
{...backButtonProps}
|
||||||
|
className={twMerge(
|
||||||
|
"p-1 -my-2 -mx-2",
|
||||||
|
"twui-breadcrumbs-back-button",
|
||||||
|
backButtonProps?.className
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
window.history.back();
|
||||||
|
backButtonProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
title="Breadcrumbs Back Button"
|
||||||
|
beforeIcon={<ChevronLeft size={20} />}
|
||||||
|
/>
|
||||||
|
{divider || (
|
||||||
|
<Divider
|
||||||
|
vertical
|
||||||
|
className={twMerge(
|
||||||
|
"twui-breadcrumbs-divider",
|
||||||
|
dividerProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.map((linkObject, index, array) => {
|
||||||
|
const isTarget = array.length - 1 == index;
|
||||||
|
|
||||||
|
if (index === links.length - 1) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={linkObject.path}
|
||||||
|
{...linkProps}
|
||||||
|
{...(isTarget ? currentLinkProps : {})}
|
||||||
|
className={twMerge(
|
||||||
|
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
|
||||||
|
"max-w-[200px] text-ellipsis overflow-hidden",
|
||||||
|
isTarget ? "current" : "",
|
||||||
|
"twui-breadcrumb-link",
|
||||||
|
linkProps?.className,
|
||||||
|
isTarget && currentLinkProps?.className
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
currentLinkProps?.title || linkObject.title
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{currentTitle || linkObject.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Link
|
||||||
|
href={linkObject.path}
|
||||||
|
{...linkProps}
|
||||||
|
{...(isTarget ? currentLinkProps : {})}
|
||||||
|
className={twMerge(
|
||||||
|
"text-xs",
|
||||||
|
isTarget ? "current" : "",
|
||||||
|
"twui-breadcrumb-link",
|
||||||
|
linkProps?.className,
|
||||||
|
isTarget && currentLinkProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentLinkProps?.title ||
|
||||||
|
linkObject.title}
|
||||||
|
</Link>
|
||||||
|
{divider || (
|
||||||
|
<Divider
|
||||||
|
vertical
|
||||||
|
{...dividerProps}
|
||||||
|
className={twMerge(
|
||||||
|
"twui-breadcrumbs-divider",
|
||||||
|
dividerProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
////////////////////////////////////////
|
||||||
|
////////////////////////////////////////
|
||||||
|
////////////////////////////////////////
|
||||||
|
}
|
||||||
|
|
||||||
|
export function twuiBreadcrumbsGenerateLinksFromUrl({
|
||||||
|
url,
|
||||||
|
excludeRegexMatch,
|
||||||
|
skipHome,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
excludeRegexMatch?: RegExp;
|
||||||
|
skipHome?: boolean;
|
||||||
|
}) {
|
||||||
|
let pathLinks = url.split("/");
|
||||||
|
|
||||||
|
let validPathLinks = [];
|
||||||
|
|
||||||
|
if (!skipHome) {
|
||||||
|
validPathLinks.push({
|
||||||
|
title: "Home",
|
||||||
|
path: url.match(/admin/) ? "/admin" : "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pathLinks.forEach((linkText, index, array) => {
|
||||||
|
if (!linkText?.match(/./)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
|
||||||
|
|
||||||
|
validPathLinks.push({
|
||||||
|
title: lowerToTitleCase(linkText),
|
||||||
|
path: (() => {
|
||||||
|
let path = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
const lnText = array[i];
|
||||||
|
if (i > index || !lnText.match(/./)) continue;
|
||||||
|
|
||||||
|
path += `/${lnText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
})(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return validPathLinks;
|
||||||
|
}
|
||||||
72
components/elements/Card.tsx
Normal file
72
components/elements/Card.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
variant?: "normal";
|
||||||
|
href?: string;
|
||||||
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
|
noHover?: boolean;
|
||||||
|
elRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
linkRef?: React.RefObject<HTMLAnchorElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # General Card
|
||||||
|
* @className twui-card
|
||||||
|
* @className twui-card-link
|
||||||
|
*
|
||||||
|
* @info use the classname `nested-link` to prevent the card from being clickable when
|
||||||
|
* a link (or the target element with this calss) inside the card is clicked.
|
||||||
|
*/
|
||||||
|
export default function Card({
|
||||||
|
href,
|
||||||
|
variant,
|
||||||
|
linkProps,
|
||||||
|
noHover,
|
||||||
|
elRef,
|
||||||
|
linkRef,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
const component = (
|
||||||
|
<div
|
||||||
|
ref={elRef}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
|
||||||
|
"border border-slate-200 dark:border-white/10 border-solid",
|
||||||
|
noHover ? "" : "twui-card",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
ref={linkRef}
|
||||||
|
href={href}
|
||||||
|
{...linkProps}
|
||||||
|
className={twMerge(
|
||||||
|
"cursor-pointer",
|
||||||
|
"twui-card",
|
||||||
|
"twui-card-link",
|
||||||
|
linkProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{component}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return component;
|
||||||
|
}
|
||||||
61
components/elements/CheckBulletPoints.tsx
Normal file
61
components/elements/CheckBulletPoints.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ComponentProps, ReactNode } from "react";
|
||||||
|
import Stack from "../layout/Stack copy";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import { Check, CheckCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type BulletPoint = {
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUI_CHECK_BULLET_POINTS_PROPS = ComponentProps<typeof Stack> & {
|
||||||
|
bulletPoints: BulletPoint[];
|
||||||
|
bulletWrapperProps?: ComponentProps<typeof Row>;
|
||||||
|
iconProps?: ComponentProps<typeof CheckCircle2>;
|
||||||
|
titleProps?: ComponentProps<typeof Span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Check Bullet Points Component
|
||||||
|
* @className_wrapper twui-check-bullet-points-wrapper
|
||||||
|
*/
|
||||||
|
export default function CheckBulletPoints({
|
||||||
|
bulletPoints,
|
||||||
|
bulletWrapperProps,
|
||||||
|
iconProps,
|
||||||
|
titleProps,
|
||||||
|
...props
|
||||||
|
}: TWUI_CHECK_BULLET_POINTS_PROPS) {
|
||||||
|
return (
|
||||||
|
<Stack {...props} className={twMerge("gap-3", props.className)}>
|
||||||
|
{bulletPoints.map((bulletPoint, index) => {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
key={index}
|
||||||
|
{...bulletWrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"gap-2 xl:flex-nowrap",
|
||||||
|
bulletWrapperProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bulletPoint.icon || (
|
||||||
|
<CheckCircle2
|
||||||
|
className="text-success min-w-[20px]"
|
||||||
|
size={20}
|
||||||
|
{...iconProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Span
|
||||||
|
{...titleProps}
|
||||||
|
className={twMerge("", titleProps?.className)}
|
||||||
|
>
|
||||||
|
{bulletPoint.title}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/elements/CodeBlock.tsx
Normal file
150
components/elements/CodeBlock.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import React, {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
PropsWithChildren,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Stack from "../layout/Stack";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
import Divider from "../layout/Divider";
|
||||||
|
|
||||||
|
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
|
||||||
|
|
||||||
|
type Props = PropsWithChildren &
|
||||||
|
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
|
||||||
|
wrapperProps?: DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
"data-title"?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
singleBlock?: boolean;
|
||||||
|
language?: (typeof TWUIPrismLanguages)[number];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # CodeBlock
|
||||||
|
*
|
||||||
|
* @className `twui-code-block-wrapper`
|
||||||
|
* @className `twui-code-pre-wrapper`
|
||||||
|
* @className `twui-code-block-pre`
|
||||||
|
* @className `twui-code-block-header`
|
||||||
|
*/
|
||||||
|
export default function CodeBlock({
|
||||||
|
children,
|
||||||
|
wrapperProps,
|
||||||
|
backgroundColor,
|
||||||
|
singleBlock,
|
||||||
|
language,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
const title = props?.["data-title"];
|
||||||
|
|
||||||
|
const finalBackgroundColor = backgroundColor || "#28272b";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...wrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||||
|
`rounded w-full transition-all items-start`,
|
||||||
|
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||||
|
"twui-code-block-wrapper",
|
||||||
|
wrapperProps?.className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
boxShadow: copied
|
||||||
|
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||||
|
: undefined,
|
||||||
|
backgroundColor: finalBackgroundColor,
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
className={twMerge(
|
||||||
|
"gap-0 w-full overflow-x-auto relative",
|
||||||
|
"max-h-[600px] overflow-y-auto"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"w-full px-1 h-10 sticky top-0 py-2",
|
||||||
|
singleBlock ? "absolute !bg-transparent" : "",
|
||||||
|
"twui-code-block-header"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: finalBackgroundColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && <span className="text-white/70">{title}</span>}
|
||||||
|
<div className="ml-auto">
|
||||||
|
{copied ? (
|
||||||
|
<Row>
|
||||||
|
<span className="text-white text-xs twui-code-block-copied-text">
|
||||||
|
Copied!
|
||||||
|
</span>
|
||||||
|
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
|
||||||
|
<Check size={15} />
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
beforeIcon={<Copy size={17} color="white" />}
|
||||||
|
className="!p-1 !bg-transparent opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
const content =
|
||||||
|
codeRef.current?.textContent;
|
||||||
|
if (!content) {
|
||||||
|
window.alert("No Content to copy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.navigator.clipboard
|
||||||
|
.writeText(content)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Copy Code Snippet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
{!singleBlock && (
|
||||||
|
<Divider className="!border-white/10 sticky top-10" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
`p-1 w-full [&_pre]:!bg-transparent`,
|
||||||
|
singleBlock ? "" : "-mt-1",
|
||||||
|
"twui-code-pre-wrapper"
|
||||||
|
)}
|
||||||
|
ref={codeRef as any}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"!my-0 whitespace-pre-wrap",
|
||||||
|
language ? `language-${language}` : "",
|
||||||
|
"twui-code-block-pre",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,101 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle";
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
active?: boolean;
|
||||||
|
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
iconWrapperProps?: DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
defaultScheme?: "light" | "dark";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Color Scheme Loader
|
||||||
|
* @className_wrapper twui-color-scheme-selector
|
||||||
|
*/
|
||||||
export default function ColorSchemeSelector({
|
export default function ColorSchemeSelector({
|
||||||
toggleProps,
|
active: initialActive,
|
||||||
|
setActive: externalSetActive,
|
||||||
|
iconWrapperProps,
|
||||||
|
defaultScheme,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
}: Props) {
|
||||||
toggleProps?: TWUI_TOGGLE_PROPS;
|
const [active, setActive] = React.useState(initialActive);
|
||||||
}) {
|
|
||||||
const [active, setActive] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
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) {
|
if (active) {
|
||||||
document.documentElement.className = "dark";
|
document.documentElement.className = "dark";
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.className = "";
|
document.documentElement.className = "light";
|
||||||
|
localStorage.setItem("theme", "light");
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("flex flex-row items-center", props.className)}
|
className={twMerge(
|
||||||
|
"flex flex-row items-center",
|
||||||
|
"twui-color-scheme-selector",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Toggle active={active} setActive={setActive} {...toggleProps} />
|
<button
|
||||||
|
title="Color Scheme Selector Button"
|
||||||
|
onClick={() => setActive(!active)}
|
||||||
|
className={twMerge(
|
||||||
|
"cursor-pointer hover:opacity-70 flex items-center justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...iconWrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"w-6 h-6 flex items-center justify-center",
|
||||||
|
iconWrapperProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active == false && <Sun />}
|
||||||
|
{active == true && <Moon />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
components/elements/CopySlug.tsx
Normal file
59
components/elements/CopySlug.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
Dispatch,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
} from "react";
|
||||||
|
import { Copy, LucideProps } from "lucide-react";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
|
||||||
|
slugText: string;
|
||||||
|
justIcon?: boolean;
|
||||||
|
noIcon?: boolean;
|
||||||
|
title?: string;
|
||||||
|
outlined?: boolean;
|
||||||
|
successMsg?: string | ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
iconProps?: LucideProps;
|
||||||
|
setToastOpen?: Dispatch<SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CopySlug({
|
||||||
|
slugText,
|
||||||
|
justIcon,
|
||||||
|
noIcon,
|
||||||
|
title,
|
||||||
|
outlined,
|
||||||
|
successMsg,
|
||||||
|
iconProps,
|
||||||
|
icon,
|
||||||
|
setToastOpen,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
title={title || slugText}
|
||||||
|
size="smaller"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
|
navigator.clipboard.writeText(slugText).then(() => {
|
||||||
|
setToastOpen?.(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setToastOpen?.(true);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
props.onClick?.(e);
|
||||||
|
}}
|
||||||
|
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
|
||||||
|
>
|
||||||
|
{noIcon
|
||||||
|
? null
|
||||||
|
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
|
||||||
|
{!justIcon && (title ? title : "Copy Slug")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
components/elements/Dropdown.tsx
Normal file
191
components/elements/Dropdown.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import React, {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
PropsWithChildren,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const TWUIDropdownContentPositions = [
|
||||||
|
"left",
|
||||||
|
"bottom-left",
|
||||||
|
"top-left",
|
||||||
|
"top",
|
||||||
|
"bottom",
|
||||||
|
"right",
|
||||||
|
"bottom-right",
|
||||||
|
"top-right",
|
||||||
|
"center",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
||||||
|
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
target: React.ReactNode;
|
||||||
|
contentWrapperProps?: DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
targetWrapperProps?: DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
debounce?: number;
|
||||||
|
openDebounce?: number;
|
||||||
|
hoverOpen?: boolean;
|
||||||
|
above?: boolean;
|
||||||
|
position?: (typeof TWUIDropdownContentPositions)[number];
|
||||||
|
topOffset?: number;
|
||||||
|
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
externalOpen?: boolean;
|
||||||
|
keepOpen?: boolean;
|
||||||
|
disableClickActions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Toggle Component
|
||||||
|
* @className_wrapper twui-dropdown-wrapper
|
||||||
|
* @className_wrapper twui-dropdown-target
|
||||||
|
* @className_wrapper twui-dropdown-content
|
||||||
|
*
|
||||||
|
* @note use the class `cancel-link` to prevent popup open on click
|
||||||
|
*/
|
||||||
|
export default function Dropdown({
|
||||||
|
contentWrapperProps,
|
||||||
|
targetWrapperProps,
|
||||||
|
hoverOpen,
|
||||||
|
above,
|
||||||
|
debounce = 200,
|
||||||
|
openDebounce = 200,
|
||||||
|
target,
|
||||||
|
position = "center",
|
||||||
|
topOffset,
|
||||||
|
externalSetOpen,
|
||||||
|
keepOpen,
|
||||||
|
disableClickActions,
|
||||||
|
externalOpen,
|
||||||
|
...props
|
||||||
|
}: TWUI_DROPDOWN_PROPS) {
|
||||||
|
const [open, setOpen] = React.useState(externalOpen);
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
let openTimeout: any;
|
||||||
|
|
||||||
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||||
|
const targetEl = e.target as HTMLElement;
|
||||||
|
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
|
||||||
|
|
||||||
|
if (!closestWrapper) {
|
||||||
|
externalSetOpen?.(false);
|
||||||
|
return setOpen(false);
|
||||||
|
}
|
||||||
|
if (closestWrapper && closestWrapper !== dropdownRef.current) {
|
||||||
|
externalSetOpen?.(false);
|
||||||
|
return setOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (keepOpen) return;
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setOpen(externalOpen);
|
||||||
|
}, [externalOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-col items-center relative",
|
||||||
|
"twui-dropdown-wrapper",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!hoverOpen) return;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.clearTimeout(openTimeout);
|
||||||
|
|
||||||
|
openTimeout = setTimeout(() => {
|
||||||
|
externalSetOpen?.(true);
|
||||||
|
setOpen(true);
|
||||||
|
}, openDebounce);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!hoverOpen) return;
|
||||||
|
|
||||||
|
window.clearTimeout(openTimeout);
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
externalSetOpen?.(false);
|
||||||
|
setOpen(false);
|
||||||
|
}, debounce);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}}
|
||||||
|
ref={dropdownRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
const targetEl = e.target as HTMLElement | null;
|
||||||
|
if (targetEl?.closest?.(".cancel-link")) return;
|
||||||
|
if (disableClickActions) return;
|
||||||
|
externalSetOpen?.(!open);
|
||||||
|
setOpen(!open);
|
||||||
|
}}
|
||||||
|
className={twMerge(
|
||||||
|
"cursor-pointer",
|
||||||
|
"twui-dropdown-target",
|
||||||
|
targetWrapperProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{target}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...contentWrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"absolute z-10 mt-1",
|
||||||
|
position == "left"
|
||||||
|
? "left-[100%] top-[50%] -translate-y-[50%]"
|
||||||
|
: position == "right"
|
||||||
|
? "right-[100%] top-[50%] -translate-y-[50%]"
|
||||||
|
: position == "bottom-left"
|
||||||
|
? "left-0 top-[100%]"
|
||||||
|
: position == "bottom-right"
|
||||||
|
? "right-0 top-[100%]"
|
||||||
|
: position == "center"
|
||||||
|
? "left-[50%] -translate-x-[50%] top-[100%]"
|
||||||
|
: position == "top"
|
||||||
|
? "left-[50%] -translate-x-[50%] bottom-[100%]"
|
||||||
|
: "top-[100%]",
|
||||||
|
above ? "-translate-y-[120%]" : "",
|
||||||
|
open ? "flex" : "hidden",
|
||||||
|
"twui-dropdown-content",
|
||||||
|
contentWrapperProps?.className
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!hoverOpen) return;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!hoverOpen) return;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
// top: `calc(100% + ${topOffset || 0}px)`,
|
||||||
|
...contentWrapperProps?.style,
|
||||||
|
}}
|
||||||
|
ref={dropdownContentRef}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
components/elements/EmptyContent.tsx
Normal file
88
components/elements/EmptyContent.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React, { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Stack from "../layout/Stack";
|
||||||
|
import Border from "./Border";
|
||||||
|
import Center from "../layout/Center";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
|
||||||
|
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||||
|
export const ToastColors = ToastStyles;
|
||||||
|
|
||||||
|
export type TWUIEmptyContentProps = ComponentProps<typeof Stack> & {
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
|
borderProps?: ComponentProps<typeof Border>;
|
||||||
|
textProps?: ComponentProps<typeof Span>;
|
||||||
|
contentWrapperProps?: ComponentProps<typeof Row>;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # EmptyC ontent Component
|
||||||
|
* @className twui-empty-content
|
||||||
|
* @className twui-empty-content-border
|
||||||
|
* @className twui-empty-content-link
|
||||||
|
*/
|
||||||
|
export default function EmptyContent({
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
linkProps,
|
||||||
|
icon,
|
||||||
|
borderProps,
|
||||||
|
textProps,
|
||||||
|
contentWrapperProps,
|
||||||
|
...props
|
||||||
|
}: TWUIEmptyContentProps) {
|
||||||
|
const mainComponent = (
|
||||||
|
<Stack
|
||||||
|
{...props}
|
||||||
|
className={twMerge("w-full", "twui-empty-content", props.className)}
|
||||||
|
>
|
||||||
|
<Border
|
||||||
|
{...borderProps}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full",
|
||||||
|
borderProps?.className,
|
||||||
|
"twui-empty-content-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Center>
|
||||||
|
<Row {...contentWrapperProps}>
|
||||||
|
{icon && <div className="opacity-50">{icon}</div>}
|
||||||
|
<Span
|
||||||
|
size="small"
|
||||||
|
{...textProps}
|
||||||
|
className={twMerge(
|
||||||
|
"opacity-70 text-foreground-light dark:text-foreground-dark",
|
||||||
|
textProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
</Center>
|
||||||
|
</Border>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...linkProps}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full",
|
||||||
|
"twui-empty-content-link",
|
||||||
|
linkProps?.className
|
||||||
|
)}
|
||||||
|
href={url}
|
||||||
|
>
|
||||||
|
{mainComponent}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainComponent;
|
||||||
|
}
|
||||||
33
components/elements/HeaderLink.tsx
Normal file
33
components/elements/HeaderLink.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
import { TwuiHeaderLink } from "./HeaderNav";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
|
||||||
|
export type TWUI_HEADER_LINK_PROPS = ComponentProps<typeof Link> & {
|
||||||
|
link: TwuiHeaderLink;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Header Nav Component
|
||||||
|
* @className_wrapper twui-header-link
|
||||||
|
*/
|
||||||
|
export default function HeaderLink({ link, ...props }: TWUI_HEADER_LINK_PROPS) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
strict={link.strict}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"grow p-2 hover:opacity-50",
|
||||||
|
"twui-header-link",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
{link.icon}
|
||||||
|
{link.title}
|
||||||
|
</Row>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
components/elements/HeaderNav.tsx
Normal file
98
components/elements/HeaderNav.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
|
||||||
|
|
||||||
|
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLElement>,
|
||||||
|
HTMLElement
|
||||||
|
> & {
|
||||||
|
headerLinks: TwuiHeaderLink[];
|
||||||
|
customDropdowns?: {
|
||||||
|
url: string;
|
||||||
|
content: ReactNode;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TwuiHeaderLink = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
strict?: boolean;
|
||||||
|
dropdown?: ReactNode;
|
||||||
|
children?: TwuiHeaderLink[];
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Header Nav Component
|
||||||
|
* @className twui-header-nav
|
||||||
|
* @className twui-header-nav-link-component
|
||||||
|
* @className twui-header-nav-link-icon
|
||||||
|
* @className twui-header-nav-link-dropdown
|
||||||
|
*/
|
||||||
|
export default function HeaderNav({
|
||||||
|
headerLinks,
|
||||||
|
customDropdowns,
|
||||||
|
...props
|
||||||
|
}: TWUI_HEADER_NAV_PROPS) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
twuiAddActiveLinksFn({ selector: ".twui-header-nav-link-component a" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"twui-header-nav w-full xl:w-auto",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Row className="gap-x-2 gap-y-2 flex-col xl:flex-row items-start xl:items-stretch">
|
||||||
|
{headerLinks.map((link, index) => {
|
||||||
|
const targetCustomDropdown = customDropdowns?.find(
|
||||||
|
(d) => d.url == link.url
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<HeaderNavLinkComponent
|
||||||
|
link={link}
|
||||||
|
key={index}
|
||||||
|
dropdown={targetCustomDropdown?.content}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddActiveLinkParams = {
|
||||||
|
selector?: string;
|
||||||
|
wrapperEl?: HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function twuiAddActiveLinksFn({
|
||||||
|
selector,
|
||||||
|
wrapperEl,
|
||||||
|
}: AddActiveLinkParams) {
|
||||||
|
(wrapperEl || document).querySelectorAll(selector || "a").forEach((ln) => {
|
||||||
|
const linkEl = ln as HTMLAnchorElement;
|
||||||
|
const isLinkStrict = linkEl.dataset.strict;
|
||||||
|
|
||||||
|
const linkAttr = linkEl.getAttribute("href");
|
||||||
|
|
||||||
|
if (window.location.pathname === "/" && linkAttr == "/") {
|
||||||
|
linkEl.classList.add("active");
|
||||||
|
} else if (
|
||||||
|
isLinkStrict &&
|
||||||
|
linkEl.getAttribute("href") === window.location.pathname
|
||||||
|
) {
|
||||||
|
linkEl.classList.add("active");
|
||||||
|
} else if (
|
||||||
|
linkAttr &&
|
||||||
|
window.location.pathname.startsWith(linkAttr) &&
|
||||||
|
!isLinkStrict
|
||||||
|
) {
|
||||||
|
linkEl.classList.add("active");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
141
components/elements/HeaderNavLinkComponent.tsx
Normal file
141
components/elements/HeaderNavLinkComponent.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/elements/HtmlToReactComponent.tsx
Normal file
34
components/elements/HtmlToReactComponent.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import HtmlToReact from "html-to-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
html: string;
|
||||||
|
componentRef?: React.RefObject<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # HTML String to React Component
|
||||||
|
* @className_wrapper twui-html-react
|
||||||
|
*/
|
||||||
|
export default function HtmlToReactComponent({
|
||||||
|
html,
|
||||||
|
componentRef,
|
||||||
|
...props
|
||||||
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
|
const htmlToReactParser = HtmlToReact.Parser();
|
||||||
|
const reactElement = htmlToReactParser.parse(html);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge("", props.className)}
|
||||||
|
ref={componentRef}
|
||||||
|
>
|
||||||
|
{reactElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
components/elements/LinkList.tsx
Normal file
160
components/elements/LinkList.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
import { twuiAddActiveLinksFn } from "./HeaderNav";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Divider from "../layout/Divider";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
|
||||||
|
export type TWUI_LINK_LIST_LINK_OBJECT = {
|
||||||
|
title?: string;
|
||||||
|
component?: ReactNode;
|
||||||
|
url?: string;
|
||||||
|
strict?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
iconPosition?: "before" | "after";
|
||||||
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
|
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
|
||||||
|
linkType?: "button" | "link";
|
||||||
|
divider?: ReactNode;
|
||||||
|
onClick?: React.MouseEventHandler<HTMLElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
links: (
|
||||||
|
| TWUI_LINK_LIST_LINK_OBJECT
|
||||||
|
| TWUI_LINK_LIST_LINK_OBJECT[]
|
||||||
|
| undefined
|
||||||
|
)[];
|
||||||
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
|
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
|
||||||
|
divider?: boolean;
|
||||||
|
dividerComponent?: ReactNode;
|
||||||
|
linkType?: "button" | "link";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Link List Component
|
||||||
|
* @description A component that renders a list of links.
|
||||||
|
* @className_wrapper twui-link-list
|
||||||
|
*/
|
||||||
|
export default function LinkList({
|
||||||
|
links,
|
||||||
|
linkProps,
|
||||||
|
buttonProps,
|
||||||
|
divider,
|
||||||
|
dividerComponent,
|
||||||
|
linkType,
|
||||||
|
...props
|
||||||
|
}: TWUI_LINK_LIST_PROPS) {
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
twuiAddActiveLinksFn({
|
||||||
|
wrapperEl: listRef.current || undefined,
|
||||||
|
selector: "a",
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-row items-center gap-1",
|
||||||
|
"twui-link-list",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{links
|
||||||
|
.flat()
|
||||||
|
.filter((ln) => Boolean(ln))
|
||||||
|
.map((link, index) => {
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
if (link.divider)
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{link.divider}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalDivider =
|
||||||
|
index < links.length - 1 &&
|
||||||
|
(dividerComponent ? (
|
||||||
|
dividerComponent
|
||||||
|
) : divider ? (
|
||||||
|
<Divider />
|
||||||
|
) : undefined);
|
||||||
|
|
||||||
|
if (linkType == "button" || link.linkType == "button") {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Button
|
||||||
|
title={link.title || "Link Button"}
|
||||||
|
variant="ghost"
|
||||||
|
{...buttonProps}
|
||||||
|
{...link.buttonProps}
|
||||||
|
className={twMerge(
|
||||||
|
"p-2 cursor-pointer whitespace-nowrap",
|
||||||
|
linkProps?.className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
link.onClick?.(e);
|
||||||
|
link.buttonProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
{link.icon}
|
||||||
|
{link.component || link.title}
|
||||||
|
</Row>
|
||||||
|
</Button>
|
||||||
|
{finalDivider}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
title={link.title}
|
||||||
|
{...linkProps}
|
||||||
|
{...link.linkProps}
|
||||||
|
className={twMerge(
|
||||||
|
"p-2 cursor-pointer whitespace-nowrap",
|
||||||
|
linkProps?.className,
|
||||||
|
link.linkProps?.className,
|
||||||
|
)}
|
||||||
|
strict={link.strict}
|
||||||
|
onClick={(e) => {
|
||||||
|
link.onClick?.(e);
|
||||||
|
link.linkProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
{!link.iconPosition ||
|
||||||
|
link.iconPosition == "before"
|
||||||
|
? link.icon
|
||||||
|
: null}
|
||||||
|
{link.component || link.title}
|
||||||
|
{link.iconPosition == "after"
|
||||||
|
? link.icon
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
</Link>
|
||||||
|
{finalDivider}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,32 +5,44 @@ type Props = DetailedHTMLProps<
|
|||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
size?: "small" | "normal" | "medium" | "large";
|
size?: "small" | "normal" | "medium" | "large" | "smaller";
|
||||||
|
svgClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Loading({ size, ...props }: Props) {
|
/**
|
||||||
|
* # Loading Component
|
||||||
|
* @className_wrapper twui-loading
|
||||||
|
*/
|
||||||
|
export default function Loading({ size, svgClassName, ...props }: Props) {
|
||||||
const sizeClassName = (() => {
|
const sizeClassName = (() => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case "small":
|
case "smaller":
|
||||||
return "w-2 h-2";
|
|
||||||
case "normal":
|
|
||||||
return "w-4 h-4";
|
return "w-4 h-4";
|
||||||
|
case "small":
|
||||||
|
return "w-5 h-5";
|
||||||
case "normal":
|
case "normal":
|
||||||
return "w-6 h-6";
|
return "w-6 h-6";
|
||||||
case "large":
|
case "large":
|
||||||
return "w-8 h-8";
|
return "w-7 h-7";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "w-4 h-4";
|
return "w-6 h-6";
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="status" {...props}>
|
<div
|
||||||
|
role="status"
|
||||||
|
{...props}
|
||||||
|
className={twMerge(`twui-loading`, props.className)}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600",
|
"text-gray animate-spin dark:text-gray-dark fill-primary",
|
||||||
sizeClassName
|
"dark:fill-white twui-loading",
|
||||||
|
sizeClassName,
|
||||||
|
svgClassName,
|
||||||
)}
|
)}
|
||||||
viewBox="0 0 100 101"
|
viewBox="0 0 100 101"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|||||||
24
components/elements/LoadingBlock.tsx
Normal file
24
components/elements/LoadingBlock.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # General paper
|
||||||
|
* @className_wrapper twui-loading-block
|
||||||
|
*/
|
||||||
|
export default function LoadingBlock({
|
||||||
|
...props
|
||||||
|
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"bg-slate-200 dark:bg-white/10",
|
||||||
|
"rounded animate-pulse w-full h-[60px]",
|
||||||
|
"twui-loading-block",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/elements/LoadingOverlay.tsx
Normal file
46
components/elements/LoadingOverlay.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Center from "../layout/Center";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
loadingProps?: ComponentProps<typeof Loading>;
|
||||||
|
label?: string;
|
||||||
|
fixed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Loading Overlay Component
|
||||||
|
* @className_wrapper twui-loading-overlay
|
||||||
|
*/
|
||||||
|
export default function LoadingOverlay({
|
||||||
|
loadingProps,
|
||||||
|
label,
|
||||||
|
fixed,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"top-0 left-0 w-full h-full z-[500]",
|
||||||
|
"bg-background-light/90 dark:bg-background-dark/90",
|
||||||
|
fixed ? "fixed" : "absolute",
|
||||||
|
props.className,
|
||||||
|
"twui-loading-overlay",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Center>
|
||||||
|
<Row>
|
||||||
|
<Loading {...loadingProps} />
|
||||||
|
{label && <Span>{label}</Span>}
|
||||||
|
</Row>
|
||||||
|
</Center>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
components/elements/Modal.tsx
Normal file
190
components/elements/Modal.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import ModalComponent from "../(partials)/ModalComponent";
|
||||||
|
import PopoverComponent from "../(partials)/PopoverComponent";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const TWUIPopoverStyles = [
|
||||||
|
"top",
|
||||||
|
"bottom",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"transform",
|
||||||
|
"bottom-left",
|
||||||
|
"bottom-right",
|
||||||
|
] as const;
|
||||||
|
export const TWUIPopoverTriggers = ["hover", "click"] as const;
|
||||||
|
|
||||||
|
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
target?: React.ReactNode;
|
||||||
|
targetRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
targetWrapperProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
open?: boolean;
|
||||||
|
isPopover?: boolean;
|
||||||
|
position?: (typeof TWUIPopoverStyles)[number];
|
||||||
|
trigger?: (typeof TWUIPopoverTriggers)[number];
|
||||||
|
debounce?: number;
|
||||||
|
onClose?: () => any;
|
||||||
|
hoverOpen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Modal Component
|
||||||
|
* @ID twui-modal-root
|
||||||
|
* @className twui-modal-content
|
||||||
|
* @className twui-modal
|
||||||
|
* @ID twui-popover-root
|
||||||
|
* @className twui-popover-content
|
||||||
|
* @className twui-popover-target
|
||||||
|
*/
|
||||||
|
export default function Modal(props: TWUI_MODAL_PROPS) {
|
||||||
|
const {
|
||||||
|
target,
|
||||||
|
targetRef,
|
||||||
|
targetWrapperProps,
|
||||||
|
open: existingOpen,
|
||||||
|
setOpen: existingSetOpen,
|
||||||
|
isPopover,
|
||||||
|
popoverReferenceRef,
|
||||||
|
trigger = "hover",
|
||||||
|
debounce = 500,
|
||||||
|
onClose,
|
||||||
|
hoverOpen,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const [open, setOpen] = React.useState(existingOpen || false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
|
||||||
|
const modalRoot = document.getElementById(IDName);
|
||||||
|
|
||||||
|
if (modalRoot) {
|
||||||
|
if (isPopover) {
|
||||||
|
modalRoot.style.zIndex = "1000";
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
} else {
|
||||||
|
const newModalRootEl = document.createElement("div");
|
||||||
|
newModalRootEl.id = IDName;
|
||||||
|
document.body.appendChild(newModalRootEl);
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
existingSetOpen?.(open);
|
||||||
|
if (open == false) onClose?.();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setOpen(existingOpen || false);
|
||||||
|
}, [existingOpen]);
|
||||||
|
|
||||||
|
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
|
||||||
|
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
|
||||||
|
|
||||||
|
const popoverTargetActiveRef = React.useRef(false);
|
||||||
|
const popoverContentActiveRef = React.useRef(false);
|
||||||
|
|
||||||
|
let closeTimeout: any;
|
||||||
|
|
||||||
|
const popoverEnterFn = React.useCallback((e: any) => {
|
||||||
|
popoverTargetActiveRef.current = true;
|
||||||
|
popoverContentActiveRef.current = false;
|
||||||
|
setOpen(true);
|
||||||
|
props.onMouseEnter?.(e);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const popoverLeaveFn = React.useCallback((e: any) => {
|
||||||
|
window.clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = setTimeout(() => {
|
||||||
|
// if (popoverTargetActiveRef.current) {
|
||||||
|
// popoverTargetActiveRef.current = false;
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (popoverContentActiveRef.current) {
|
||||||
|
popoverContentActiveRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
}, debounce);
|
||||||
|
props.onMouseLeave?.(e);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||||
|
const targetEl = e.target as HTMLElement;
|
||||||
|
|
||||||
|
const closestWrapper = targetEl.closest(".twui-popover-content");
|
||||||
|
const closestTarget = targetEl.closest(".twui-popover-target");
|
||||||
|
|
||||||
|
if (closestTarget) return;
|
||||||
|
|
||||||
|
if (!closestWrapper) {
|
||||||
|
return setOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isPopover) return;
|
||||||
|
document.addEventListener("click", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{target ? (
|
||||||
|
<div
|
||||||
|
{...targetWrapperProps}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(!open);
|
||||||
|
}}
|
||||||
|
ref={finalTargetRef}
|
||||||
|
onMouseEnter={
|
||||||
|
isPopover && (trigger === "hover" || hoverOpen)
|
||||||
|
? popoverEnterFn
|
||||||
|
: targetWrapperProps?.onMouseEnter
|
||||||
|
}
|
||||||
|
onMouseLeave={
|
||||||
|
isPopover && (trigger === "hover" || hoverOpen)
|
||||||
|
? popoverLeaveFn
|
||||||
|
: targetWrapperProps?.onMouseLeave
|
||||||
|
}
|
||||||
|
className={twMerge(
|
||||||
|
"twui-popover-target",
|
||||||
|
targetWrapperProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{target}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{ready ? (
|
||||||
|
isPopover ? (
|
||||||
|
<PopoverComponent
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
targetElRef={finalPopoverReferenceRef}
|
||||||
|
debounce={debounce}
|
||||||
|
popoverTargetActiveRef={popoverTargetActiveRef}
|
||||||
|
popoverContentActiveRef={popoverContentActiveRef}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ModalComponent {...props} open={open} setOpen={setOpen} />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
components/elements/Pagination.tsx
Normal file
126
components/elements/Pagination.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||||
|
import _ from "lodash";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
import EmptyContent from "./EmptyContent";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
|
||||||
|
type Props = ComponentProps<typeof Row> & {
|
||||||
|
page?: number;
|
||||||
|
setPage?: Dispatch<SetStateAction<number>>;
|
||||||
|
count?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Pagination Component
|
||||||
|
* @param param0
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export default function Pagination({
|
||||||
|
count,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
limit,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
if (!count || !page || !limit)
|
||||||
|
return (
|
||||||
|
<EmptyContent title={`count, page, and limit are all required`} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLimit = limit * page >= count;
|
||||||
|
|
||||||
|
const pages = Math.ceil(count / limit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full justify-between flex-nowrap",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pages > 1 && (
|
||||||
|
<Button
|
||||||
|
title="Next Page Button"
|
||||||
|
onClick={() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
setPage?.((prev) => prev - 1);
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
className={twMerge(
|
||||||
|
"p-1",
|
||||||
|
page == 1 ? "opacity-40 pointer-events-none" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row className={twMerge("gap-6 w-full flex-nowrap justify-center")}>
|
||||||
|
<Span size="small" variant="faded">
|
||||||
|
Page {page} / {pages}
|
||||||
|
</Span>
|
||||||
|
{pages > 1 && (
|
||||||
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"flex-nowrap overflow-x-auto p-1 max-w-[90%]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Array(pages)
|
||||||
|
.fill(0)
|
||||||
|
.map((p, index) => {
|
||||||
|
const isCurrent = page == index + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
title={`Page ${index + 1}`}
|
||||||
|
onClick={() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
setPage?.(index + 1);
|
||||||
|
}}
|
||||||
|
variant={
|
||||||
|
isCurrent ? "normal" : "outlined"
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
color={isCurrent ? "primary" : "gray"}
|
||||||
|
className={twMerge(
|
||||||
|
"p-1 w-6 h-6 min-w-6"
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{pages > 1 && (
|
||||||
|
<Button
|
||||||
|
title="Next Page Button"
|
||||||
|
onClick={() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
setPage?.((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
className={twMerge(
|
||||||
|
"p-1",
|
||||||
|
isLimit ? "opacity-40 pointer-events-none" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
components/elements/Paper.tsx
Normal file
36
components/elements/Paper.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # General paper
|
||||||
|
* @className_wrapper twui-paper
|
||||||
|
*/
|
||||||
|
export default function Paper({
|
||||||
|
variant,
|
||||||
|
linkProps,
|
||||||
|
componentRef,
|
||||||
|
...props
|
||||||
|
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
|
variant?: "normal";
|
||||||
|
linkProps?: DetailedHTMLProps<
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
HTMLAnchorElement
|
||||||
|
>;
|
||||||
|
componentRef?: RefObject<HTMLDivElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
ref={componentRef as any}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
|
||||||
|
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||||
|
"relative",
|
||||||
|
"twui-paper",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
components/elements/Popover.tsx
Normal file
9
components/elements/Popover.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import Modal, { TWUI_MODAL_PROPS } from "./Modal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Popover Component
|
||||||
|
*/
|
||||||
|
export default function Popover(props: TWUI_MODAL_PROPS) {
|
||||||
|
return <Modal {...props} isPopover />;
|
||||||
|
}
|
||||||
49
components/elements/RemoteCodeBlock.tsx
Normal file
49
components/elements/RemoteCodeBlock.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { serialize } from "next-mdx-remote/serialize";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypePrismPlus from "rehype-prism-plus";
|
||||||
|
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||||
|
import { useMDXComponents } from "../mdx/mdx-components";
|
||||||
|
|
||||||
|
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: string;
|
||||||
|
refresh?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # CodeBlock
|
||||||
|
*
|
||||||
|
* @className `twui-remote-code-block-wrapper`
|
||||||
|
*/
|
||||||
|
export default function RemoteCodeBlock({ content, refresh }: Props) {
|
||||||
|
const [mdxSource, setMdxSource] =
|
||||||
|
React.useState<MDXRemoteSerializeResult<any>>();
|
||||||
|
|
||||||
|
const { components } = useMDXComponents();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
serialize(content, {
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
rehypePlugins: [rehypePrismPlus],
|
||||||
|
},
|
||||||
|
}).then((mdxSrc) => {
|
||||||
|
setMdxSource(mdxSrc);
|
||||||
|
});
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
if (!mdxSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MDXRemote
|
||||||
|
{...mdxSource}
|
||||||
|
components={{
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/elements/Search.tsx
Normal file
114
components/elements/Search.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Input, { InputProps } from "../form/Input";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import { Search as SearchIcon } from "lucide-react";
|
||||||
|
import React, { DetailedHTMLProps } from "react";
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
|
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
dispatch?: (value?: string) => void;
|
||||||
|
changeHandler?: (value?: string) => void;
|
||||||
|
delay?: number;
|
||||||
|
inputProps?: InputProps<KeyType>;
|
||||||
|
buttonProps?: DetailedHTMLProps<
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
HTMLButtonElement
|
||||||
|
>;
|
||||||
|
loading?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
componentRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Search Component
|
||||||
|
* @className_wrapper twui-search-wrapper
|
||||||
|
* @className_circle twui-search-input
|
||||||
|
* @className_circle twui-search-button
|
||||||
|
*/
|
||||||
|
export default function Search<KeyType extends string>({
|
||||||
|
dispatch,
|
||||||
|
changeHandler,
|
||||||
|
delay = 500,
|
||||||
|
inputProps,
|
||||||
|
buttonProps,
|
||||||
|
loading,
|
||||||
|
placeholder,
|
||||||
|
componentRef,
|
||||||
|
...props
|
||||||
|
}: SearchProps<KeyType>) {
|
||||||
|
const [input, setInput] = React.useState(
|
||||||
|
props.defaultValue?.toString() || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
dispatch?.(input);
|
||||||
|
changeHandler?.(input);
|
||||||
|
}, delay);
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// React.useEffect(() => {
|
||||||
|
// if (props.autoFocus) {
|
||||||
|
// inputRef.current?.focus();
|
||||||
|
// }
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
|
||||||
|
"twui-search-wrapper",
|
||||||
|
props?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder={placeholder || "Search"}
|
||||||
|
{...inputProps}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
className={twMerge(
|
||||||
|
"rounded-r-none!",
|
||||||
|
"twui-search-input",
|
||||||
|
inputProps?.className
|
||||||
|
)}
|
||||||
|
wrapperProps={{
|
||||||
|
className: "rounded-r-none!",
|
||||||
|
}}
|
||||||
|
componentRef={inputRef}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loadingProps={{ size: "small" }}
|
||||||
|
{...buttonProps}
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
className={twMerge(
|
||||||
|
"rounded-l-none! ml-[1px]",
|
||||||
|
"twui-search-button",
|
||||||
|
buttonProps?.className
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch?.(input);
|
||||||
|
changeHandler?.(input);
|
||||||
|
}}
|
||||||
|
title="Search Button"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<SearchIcon
|
||||||
|
className="text-slate-800 dark:text-white"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
components/elements/SingleLineCodeBlock.tsx
Normal file
24
components/elements/SingleLineCodeBlock.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React, { DetailedHTMLProps, PropsWithChildren } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = PropsWithChildren &
|
||||||
|
DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Single Line CodeBlock
|
||||||
|
*/
|
||||||
|
export default function SingleLineCodeBlock({ children, ...props }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"[&_.twui-code-block-header]:absolute [&_.twui-code-block-header]:!bg-transparent",
|
||||||
|
"[&_.twui-code-block-header]:mt-2 [&_.twui-code-block-header]:pr-3 [&_.twui-divider]:hidden",
|
||||||
|
"[&_pre]:!pr-14 [&_.twui-code-block-copied-text]:!hidden",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
components/elements/StarRating.tsx
Normal file
168
components/elements/StarRating.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { LucideProps, Star } from "lucide-react";
|
||||||
|
import React, {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
ForwardRefExoticComponent,
|
||||||
|
HTMLAttributes,
|
||||||
|
RefAttributes,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type StarProps = {
|
||||||
|
total?: number;
|
||||||
|
value?: number;
|
||||||
|
size?: number;
|
||||||
|
starProps?: LucideProps;
|
||||||
|
allowRating?: boolean;
|
||||||
|
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
changeHandler?: (value: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> &
|
||||||
|
StarProps;
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Star Rating Component
|
||||||
|
* @className_wrapper twui-star-rating
|
||||||
|
*/
|
||||||
|
export default function StarRating({
|
||||||
|
total = 5,
|
||||||
|
value = 0,
|
||||||
|
size,
|
||||||
|
starProps,
|
||||||
|
allowRating,
|
||||||
|
setValueExternal,
|
||||||
|
changeHandler,
|
||||||
|
...props
|
||||||
|
}: TWUI_STAR_RATING_PROPS) {
|
||||||
|
const totalArray = Array(total).fill(null);
|
||||||
|
|
||||||
|
const [finalValue, setFinalValue] = React.useState(value);
|
||||||
|
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
|
||||||
|
|
||||||
|
const starClicked = React.useRef(false);
|
||||||
|
const sectionHovered = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setValueExternal?.(finalValue);
|
||||||
|
}, 500);
|
||||||
|
}, [selectedStarValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"flex flex-row items-center gap-0 -ml-[2px]",
|
||||||
|
"twui-star-rating",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
sectionHovered.current = true;
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
sectionHovered.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totalArray.map((_, index) => {
|
||||||
|
const isActive = index + 1 <= finalValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StarComponent
|
||||||
|
{...{
|
||||||
|
total,
|
||||||
|
value,
|
||||||
|
size,
|
||||||
|
starProps,
|
||||||
|
index,
|
||||||
|
allowRating,
|
||||||
|
finalValue,
|
||||||
|
setFinalValue,
|
||||||
|
starClicked,
|
||||||
|
selectedStarValue,
|
||||||
|
sectionHovered,
|
||||||
|
setSelectedStarValue,
|
||||||
|
isActive,
|
||||||
|
changeHandler,
|
||||||
|
}}
|
||||||
|
key={index}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarComponent({
|
||||||
|
size = 20,
|
||||||
|
starProps,
|
||||||
|
index,
|
||||||
|
allowRating,
|
||||||
|
setFinalValue,
|
||||||
|
starClicked,
|
||||||
|
sectionHovered,
|
||||||
|
setSelectedStarValue,
|
||||||
|
selectedStarValue,
|
||||||
|
isActive,
|
||||||
|
changeHandler,
|
||||||
|
}: StarProps & {
|
||||||
|
index: number;
|
||||||
|
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
starClicked: React.MutableRefObject<boolean>;
|
||||||
|
sectionHovered: React.MutableRefObject<boolean>;
|
||||||
|
selectedStarValue: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!allowRating) return;
|
||||||
|
setFinalValue(index + 1);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (!allowRating) return;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sectionHovered.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!starClicked.current) {
|
||||||
|
setFinalValue(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStarValue) {
|
||||||
|
setFinalValue(selectedStarValue);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!allowRating) return;
|
||||||
|
|
||||||
|
starClicked.current = true;
|
||||||
|
setSelectedStarValue(index + 1);
|
||||||
|
changeHandler?.(index + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={size}
|
||||||
|
className={twMerge(
|
||||||
|
"text-slate-300 dark:text-white/20",
|
||||||
|
isActive &&
|
||||||
|
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
|
||||||
|
// allowRating &&
|
||||||
|
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
|
||||||
|
starProps?.className,
|
||||||
|
)}
|
||||||
|
{...starProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/elements/Table.tsx
Normal file
76
components/elements/Table.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React from "react";
|
||||||
|
import EmptyContent from "./EmptyContent";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data?: { [k: string]: any }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Table({ data }: Props) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyContent
|
||||||
|
title="No results"
|
||||||
|
borderProps={{ className: "!p-2" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={twMerge("overflow-x-auto w-full")}>
|
||||||
|
<table
|
||||||
|
className={twMerge(
|
||||||
|
"min-w-full divide-y divide-gray dark:divide-gray-dark"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<thead className="bg-gray dark:bg-gray-dark">
|
||||||
|
<tr>
|
||||||
|
{headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header}
|
||||||
|
className={twMerge(
|
||||||
|
"px-3 py-2 text-left opacity-50",
|
||||||
|
"font-semibold"
|
||||||
|
)}
|
||||||
|
title={header}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={twMerge(
|
||||||
|
"bg-background-light dark:bg-background-dark",
|
||||||
|
"divide-y divide-gray dark:divide-gray-dark"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className={twMerge(
|
||||||
|
"hover:bg-gray/20 dark:hover:bg-gray-dark/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{headers.map((header) => (
|
||||||
|
<td
|
||||||
|
key={`${header}-${index}`}
|
||||||
|
className={twMerge(
|
||||||
|
"px-3 py-2 whitespace-nowrap text-foreground-light",
|
||||||
|
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
|
||||||
|
"overflow-ellipsis"
|
||||||
|
)}
|
||||||
|
title={row[header]}
|
||||||
|
>
|
||||||
|
{row[header]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
components/elements/Tabs.tsx
Normal file
176
components/elements/Tabs.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Border from "./Border";
|
||||||
|
import Stack from "../layout/Stack";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import twuiSlugify from "../utils/slugify";
|
||||||
|
|
||||||
|
export type TWUITabsObject = {
|
||||||
|
title: string;
|
||||||
|
value?: string;
|
||||||
|
content?: React.ReactNode;
|
||||||
|
defaultActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||||
|
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
|
||||||
|
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||||
|
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
centered?: boolean;
|
||||||
|
debounce?: number;
|
||||||
|
/**
|
||||||
|
* React Component to display when switching
|
||||||
|
*/
|
||||||
|
switchComponent?: ReactNode;
|
||||||
|
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
|
changeHandler?: (value: TWUITabsObject) => void;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
hrefUpdate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Tabs Component
|
||||||
|
* @className twui-tabs-wrapper
|
||||||
|
* @className twui-tab-buttons
|
||||||
|
* @className twui-tab-button-active
|
||||||
|
* @className twui-tab-buttons-wrapper
|
||||||
|
* @className twui-tab-buttons-container
|
||||||
|
* @className twui-tabs-border
|
||||||
|
*/
|
||||||
|
export default function Tabs({
|
||||||
|
tabsContentArray,
|
||||||
|
tabsBorderProps,
|
||||||
|
tabsButtonsWrapperProps,
|
||||||
|
centered,
|
||||||
|
debounce = 100,
|
||||||
|
switchComponent,
|
||||||
|
setActiveValue: existingSetActiveValue,
|
||||||
|
changeHandler,
|
||||||
|
defaultValue,
|
||||||
|
hrefUpdate,
|
||||||
|
...props
|
||||||
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
|
const finalTabsContentArray = tabsContentArray
|
||||||
|
.flat()
|
||||||
|
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
|
||||||
|
|
||||||
|
const values = finalTabsContentArray.map(
|
||||||
|
(obj) => obj.value || twuiSlugify(obj.title),
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultActiveObj = finalTabsContentArray.find(
|
||||||
|
(ctn) => ctn.defaultActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [activeValue, setActiveValue] = React.useState(
|
||||||
|
defaultValue
|
||||||
|
? defaultValue
|
||||||
|
: defaultActiveObj
|
||||||
|
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||||
|
: values[0] || undefined,
|
||||||
|
);
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
|
||||||
|
const targetContent = finalTabsContentArray.find(
|
||||||
|
(ctn) =>
|
||||||
|
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
existingSetActiveValue?.(activeValue);
|
||||||
|
if (targetContent && activeValue) {
|
||||||
|
changeHandler?.(targetContent);
|
||||||
|
|
||||||
|
if (hrefUpdate) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", activeValue);
|
||||||
|
window.history.pushState({}, "", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeValue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (hrefUpdate) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
const activeTab = url.searchParams.get("tab");
|
||||||
|
|
||||||
|
if (activeTab && activeValue !== activeTab) {
|
||||||
|
setActiveValue(undefined);
|
||||||
|
setActiveValue(activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setReady(true);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
{...props}
|
||||||
|
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...tabsButtonsWrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full",
|
||||||
|
"twui-tab-buttons-wrapper",
|
||||||
|
tabsButtonsWrapperProps?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Border
|
||||||
|
className="p-0 w-full overflow-hidden twui-tabs-border"
|
||||||
|
{...tabsBorderProps}
|
||||||
|
>
|
||||||
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
|
||||||
|
centered && "justify-center",
|
||||||
|
"twui-tab-buttons-container",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{values.map((value, index) => {
|
||||||
|
const targetObject = finalTabsContentArray.find(
|
||||||
|
(ctn) =>
|
||||||
|
ctn.value == value ||
|
||||||
|
twuiSlugify(ctn.title) == value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActive = value == activeValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
|
||||||
|
isActive
|
||||||
|
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
|
||||||
|
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
|
||||||
|
" cursor-pointer",
|
||||||
|
"twui-tab-buttons",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveValue(undefined);
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveValue(value);
|
||||||
|
}, debounce);
|
||||||
|
}}
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
{targetObject?.title}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</Border>
|
||||||
|
</div>
|
||||||
|
{activeValue ? targetContent?.content : switchComponent || null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/elements/Tag.tsx
Normal file
104
components/elements/Tag.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export type TWUITabsObject = {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
content: React.ReactNode;
|
||||||
|
defaultActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
||||||
|
React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
||||||
|
variant?: "normal" | "outlined" | "ghost";
|
||||||
|
href?: string;
|
||||||
|
newTab?: boolean;
|
||||||
|
linkProps?: React.DetailedHTMLProps<
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
HTMLAnchorElement
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Tabs Component
|
||||||
|
* @className twui-tag
|
||||||
|
* @className twui-tag-primary-outlined
|
||||||
|
*/
|
||||||
|
export default function Tag({
|
||||||
|
color,
|
||||||
|
variant,
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
newTab,
|
||||||
|
linkProps,
|
||||||
|
...props
|
||||||
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
|
const mainComponent = (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||||
|
"text-center flex items-center justify-center",
|
||||||
|
color == "secondary"
|
||||||
|
? "bg-secondary text-white outline-secbg-secondary"
|
||||||
|
: color == "success"
|
||||||
|
? "bg-success outline-success text-white"
|
||||||
|
: color == "error"
|
||||||
|
? "bg-orange-700 outline-orange-700"
|
||||||
|
: color == "gray"
|
||||||
|
? twMerge(
|
||||||
|
"bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
|
||||||
|
"text-slate-800 dark:text-white"
|
||||||
|
)
|
||||||
|
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
|
||||||
|
variant == "outlined"
|
||||||
|
? "!bg-transparent outline-1 " +
|
||||||
|
(color == "secondary"
|
||||||
|
? "text-secondary"
|
||||||
|
: color == "success"
|
||||||
|
? "text-success dark:text-success-dark"
|
||||||
|
: color == "error"
|
||||||
|
? "text-orange-700"
|
||||||
|
: color == "gray"
|
||||||
|
? "text-slate-700 dark:text-white/80"
|
||||||
|
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
|
||||||
|
: variant == "ghost"
|
||||||
|
? "!bg-transparent outline-none border-none " +
|
||||||
|
(color == "secondary"
|
||||||
|
? "text-secondary"
|
||||||
|
: color == "success"
|
||||||
|
? "text-success dark:text-success-dark"
|
||||||
|
: color == "error"
|
||||||
|
? "text-orange-700"
|
||||||
|
: color == "gray"
|
||||||
|
? "text-slate-700 dark:text-white/80"
|
||||||
|
: "text-primary dark:text-primary-dark")
|
||||||
|
: "",
|
||||||
|
|
||||||
|
"twui-tag",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={newTab ? "_blank" : undefined}
|
||||||
|
{...linkProps}
|
||||||
|
className={twMerge("hover:opacity-80", linkProps?.className)}
|
||||||
|
>
|
||||||
|
{mainComponent}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainComponent;
|
||||||
|
}
|
||||||
117
components/elements/Toast.tsx
Normal file
117
components/elements/Toast.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Card from "./Card";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
|
||||||
|
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||||
|
export const ToastColors = ToastStyles;
|
||||||
|
|
||||||
|
export type TWUIToastProps = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
open?: boolean;
|
||||||
|
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
closeDispatch?: (open?: boolean) => void;
|
||||||
|
closeDelay?: number;
|
||||||
|
color?: (typeof ToastStyles)[number];
|
||||||
|
};
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Toast Component
|
||||||
|
* @className twui-toast-root
|
||||||
|
* @className twui-toast
|
||||||
|
* @className twui-toast-success
|
||||||
|
* @className twui-toast-error
|
||||||
|
*/
|
||||||
|
export default function Toast({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
closeDelay = 4000,
|
||||||
|
color,
|
||||||
|
closeDispatch,
|
||||||
|
...props
|
||||||
|
}: TWUIToastProps) {
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const IDName = "twui-toast-root";
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const toastRoot = document.getElementById(IDName);
|
||||||
|
|
||||||
|
if (toastRoot) {
|
||||||
|
setReady(true);
|
||||||
|
} else {
|
||||||
|
const newToastRootEl = document.createElement("div");
|
||||||
|
newToastRootEl.id = IDName;
|
||||||
|
document.body.appendChild(newToastRootEl);
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ready || !open) return;
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setOpen?.(false);
|
||||||
|
closeDispatch?.(open);
|
||||||
|
}, closeDelay);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
setOpen?.(false);
|
||||||
|
closeDispatch?.(open);
|
||||||
|
};
|
||||||
|
}, [ready, open]);
|
||||||
|
|
||||||
|
if (!ready) return null;
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<Card
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"fixed bottom-4 right-4 z-[250] border-none",
|
||||||
|
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
|
||||||
|
color == "success"
|
||||||
|
? "bg-success-dark dark:bg-success-dark twui-toast-success"
|
||||||
|
: color == "error"
|
||||||
|
? "bg-error dark:bg-error-dark twui-toast-error"
|
||||||
|
: "",
|
||||||
|
props.className,
|
||||||
|
"twui-toast",
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setOpen?.(false);
|
||||||
|
closeDispatch?.(open);
|
||||||
|
}, closeDelay);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Span
|
||||||
|
className={twMerge(
|
||||||
|
"absolute top-2 right-2 z-[100] cursor-pointer",
|
||||||
|
"text-white",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen?.(false);
|
||||||
|
closeDispatch?.(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</Span>
|
||||||
|
<Span className={twMerge("text-white! font-semibold")}>
|
||||||
|
{props.children}
|
||||||
|
</Span>
|
||||||
|
</Card>,
|
||||||
|
document.getElementById(IDName) as HTMLElement,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
|
||||||
circleProps?: DetailedHTMLProps<
|
circleProps?: DetailedHTMLProps<
|
||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
@ -36,17 +36,21 @@ export default function Toggle({
|
|||||||
)}
|
)}
|
||||||
onClick={() => setActive?.(!active)}
|
onClick={() => setActive?.(!active)}
|
||||||
>
|
>
|
||||||
<div
|
{typeof active == "undefined" ? (
|
||||||
{...circleProps}
|
<div className="w-3.5 h-3.5 twui-toggle-circle"></div>
|
||||||
className={twMerge(
|
) : (
|
||||||
"w-3.5 h-3.5 rounded-full ",
|
<div
|
||||||
active
|
{...circleProps}
|
||||||
? "bg-blue-600 dark:bg-blue-500"
|
className={twMerge(
|
||||||
: "bg-slate-300 dark:bg-white/40",
|
"w-3.5 h-3.5 rounded-full ",
|
||||||
"twui-toggle-circle",
|
active
|
||||||
circleProps?.className
|
? "bg-blue-600 dark:bg-blue-500"
|
||||||
)}
|
: "bg-slate-300 dark:bg-white/40",
|
||||||
></div>
|
"twui-toggle-circle",
|
||||||
|
circleProps?.className
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
components/elements/ai/AIPromptActionSection.tsx
Normal file
81
components/elements/ai/AIPromptActionSection.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Send, X } from "lucide-react";
|
||||||
|
import React, { Dispatch, SetStateAction } from "react";
|
||||||
|
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||||
|
import Row from "../../layout/Row";
|
||||||
|
import Button from "../../layout/Button";
|
||||||
|
import CopySlug from "../CopySlug";
|
||||||
|
import AIPromptHistoryModal from "./AIPromptHistoryModal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
streamRes: string;
|
||||||
|
setStreamRes: Dispatch<SetStateAction<string>>;
|
||||||
|
setPrompt: Dispatch<SetStateAction<string>>;
|
||||||
|
loading: boolean;
|
||||||
|
promptFn: (prompt: string) => void;
|
||||||
|
history: ChatCompletionMessageParam[];
|
||||||
|
prompt: string;
|
||||||
|
currentPromptRef: React.MutableRefObject<string>;
|
||||||
|
promptInputRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPromptActionSection({
|
||||||
|
streamRes,
|
||||||
|
setStreamRes,
|
||||||
|
loading,
|
||||||
|
promptFn,
|
||||||
|
history,
|
||||||
|
prompt,
|
||||||
|
setPrompt,
|
||||||
|
currentPromptRef,
|
||||||
|
promptInputRef,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Row className="w-full justify-between">
|
||||||
|
<Row className="gap-4">
|
||||||
|
{streamRes.match(/./) && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Button
|
||||||
|
title="Clear AI Result"
|
||||||
|
variant="ghost"
|
||||||
|
size="smaller"
|
||||||
|
color="gray"
|
||||||
|
className="px-0"
|
||||||
|
beforeIcon={<X size={20} />}
|
||||||
|
onClick={() => {
|
||||||
|
setStreamRes("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CopySlug
|
||||||
|
slugText={streamRes}
|
||||||
|
justIcon
|
||||||
|
iconProps={{ size: 18 }}
|
||||||
|
title="Copy Content"
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<AIPromptHistoryModal history={history} />
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
|
<Button
|
||||||
|
title="Send Prompt"
|
||||||
|
beforeIcon={<Send size={20} />}
|
||||||
|
loading={loading}
|
||||||
|
className="p-2"
|
||||||
|
onClick={() => {
|
||||||
|
currentPromptRef.current = prompt;
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrompt("");
|
||||||
|
if (promptInputRef.current) {
|
||||||
|
promptInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
promptFn(prompt);
|
||||||
|
}}
|
||||||
|
loadingProps={{ size: "smaller" }}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/elements/ai/AIPromptBlock.tsx
Normal file
104
components/elements/ai/AIPromptBlock.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||||
|
import React from "react";
|
||||||
|
import Paper from "../Paper";
|
||||||
|
import Stack from "../../layout/Stack";
|
||||||
|
import AIPromptPreview from "./AIPromptPreview";
|
||||||
|
import LoadingOverlay from "../LoadingOverlay";
|
||||||
|
import Textarea from "../../form/Textarea";
|
||||||
|
import AIPromptActionSection from "./AIPromptActionSection";
|
||||||
|
import Card from "../Card";
|
||||||
|
import Row from "../../layout/Row";
|
||||||
|
import Span from "../../layout/Span";
|
||||||
|
import { MessageCircleMore } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
model?: string;
|
||||||
|
promptFn: (prompt: string) => void;
|
||||||
|
history?: ChatCompletionMessageParam[];
|
||||||
|
loading?: boolean;
|
||||||
|
mdRes?: string;
|
||||||
|
setMdRes: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPromptBlock({
|
||||||
|
model,
|
||||||
|
promptFn,
|
||||||
|
history = [],
|
||||||
|
loading = false,
|
||||||
|
mdRes = "",
|
||||||
|
setMdRes,
|
||||||
|
placeholder,
|
||||||
|
}: Props) {
|
||||||
|
const [prompt, setPrompt] = React.useState("");
|
||||||
|
const currentPromptRef = React.useRef("");
|
||||||
|
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="">
|
||||||
|
<Stack className="w-full">
|
||||||
|
{currentPromptRef.current && (
|
||||||
|
<Row className="w-full justify-end">
|
||||||
|
<Card className="py-1.5 px-2.5 text-xs">
|
||||||
|
<Row>
|
||||||
|
<Span>{currentPromptRef.current}</Span>
|
||||||
|
<MessageCircleMore
|
||||||
|
size={15}
|
||||||
|
opacity={0.5}
|
||||||
|
className="-mt-px"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<AIPromptPreview
|
||||||
|
setStreamRes={setMdRes}
|
||||||
|
streamRes={mdRes}
|
||||||
|
history={history}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<Stack className="w-full relative">
|
||||||
|
{loading && <LoadingOverlay />}
|
||||||
|
<Textarea
|
||||||
|
placeholder={
|
||||||
|
placeholder ||
|
||||||
|
(model ? `Prompt ${model}` : "Prompt AI")
|
||||||
|
}
|
||||||
|
wrapperProps={{ className: "outline-none" }}
|
||||||
|
wrapperWrapperProps={{ className: "w-full" }}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPrompt(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
currentPromptRef.current = prompt;
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrompt("");
|
||||||
|
if (promptInputRef.current) {
|
||||||
|
promptInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
promptFn(prompt);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
componentRef={promptInputRef}
|
||||||
|
// autoFocus
|
||||||
|
/>
|
||||||
|
<AIPromptActionSection
|
||||||
|
loading={loading}
|
||||||
|
promptFn={promptFn}
|
||||||
|
setStreamRes={setMdRes}
|
||||||
|
streamRes={mdRes}
|
||||||
|
history={history}
|
||||||
|
prompt={prompt}
|
||||||
|
setPrompt={setPrompt}
|
||||||
|
currentPromptRef={currentPromptRef}
|
||||||
|
promptInputRef={promptInputRef as any}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
components/elements/ai/AIPromptHistoryModal.tsx
Normal file
99
components/elements/ai/AIPromptHistoryModal.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/elements/ai/AIPromptPreview.tsx
Normal file
53
components/elements/ai/AIPromptPreview.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import Divider from "../../layout/Divider";
|
||||||
|
import Stack from "../../layout/Stack";
|
||||||
|
import React from "react";
|
||||||
|
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
|
||||||
|
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
streamRes: string;
|
||||||
|
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
history: ChatCompletionMessageParam[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPromptPreview({
|
||||||
|
setStreamRes,
|
||||||
|
streamRes,
|
||||||
|
history,
|
||||||
|
loading,
|
||||||
|
}: Props) {
|
||||||
|
const responseContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isContentInterrupted = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isContentInterrupted.current) return;
|
||||||
|
|
||||||
|
if (responseContentRef.current) {
|
||||||
|
responseContentRef.current.scrollTop =
|
||||||
|
responseContentRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [streamRes]);
|
||||||
|
|
||||||
|
if (loading || !streamRes?.match(/./)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full">
|
||||||
|
<MarkdownEditorPreviewComponent
|
||||||
|
value={streamRes}
|
||||||
|
maxHeight="40vh"
|
||||||
|
wrapperProps={{
|
||||||
|
className: "border-none p-0 ai-response-content",
|
||||||
|
componentRef: responseContentRef as any,
|
||||||
|
onMouseEnter: () => {
|
||||||
|
isContentInterrupted.current = true;
|
||||||
|
},
|
||||||
|
onMouseLeave: () => {
|
||||||
|
isContentInterrupted.current = false;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/elements/lucide-icon.tsx
Normal file
20
components/elements/lucide-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { LucideProps } from "lucide-react";
|
||||||
|
import * as icons from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type TWUILucideIconName = keyof typeof icons;
|
||||||
|
|
||||||
|
export type TWUILucideIconProps = LucideProps & {
|
||||||
|
name: TWUILucideIconName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LucideIcon({ name, ...props }: TWUILucideIconProps) {
|
||||||
|
const IconComponent = icons[name] as any;
|
||||||
|
|
||||||
|
if (!IconComponent) {
|
||||||
|
console.warn(`Lucide icon "${name}" not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <IconComponent {...props} />;
|
||||||
|
}
|
||||||
146
components/form/Checkbox.tsx
Normal file
146
components/form/Checkbox.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
422
components/form/FileUpload.tsx
Normal file
422
components/form/FileUpload.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,21 +1,49 @@
|
|||||||
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
|
import _ from "lodash";
|
||||||
|
import { DetailedHTMLProps, FormHTMLAttributes, RefObject } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
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
|
* # Form Element
|
||||||
* @className twui-form
|
* @className twui-form
|
||||||
*/
|
*/
|
||||||
export default function Form({
|
export default function Form<
|
||||||
...props
|
T extends { [key: string]: any } = { [key: string]: any },
|
||||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>) {
|
>({ formRef, ...props }: Props<T>) {
|
||||||
|
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
{...props}
|
{...finalProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col items-stretch gap-2 w-full bg-transparent",
|
"flex flex-col items-stretch gap-2 w-full bg-transparent",
|
||||||
"twui-form",
|
"twui-form",
|
||||||
props.className
|
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}
|
{props.children}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
255
components/form/ImageUpload.tsx
Normal file
255
components/form/ImageUpload.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* # Input Element
|
|
||||||
* @className twui-input
|
|
||||||
*/
|
|
||||||
export default function Input({
|
|
||||||
...props
|
|
||||||
}: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...props}
|
|
||||||
className={twMerge(
|
|
||||||
"w-full px-4 py-2 border rounded-md",
|
|
||||||
"border-slate-300 dark:border-white/20",
|
|
||||||
"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-black",
|
|
||||||
"twui-input",
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
125
components/form/Input/NumberInputButtons.tsx
Normal file
125
components/form/Input/NumberInputButtons.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React 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"> & {
|
||||||
|
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
getNormalizedValue: (v: string) => void;
|
||||||
|
buttonDownRef: React.MutableRefObject<boolean>;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Input Number Text Buttons
|
||||||
|
*/
|
||||||
|
export default function NumberInputButtons({
|
||||||
|
getNormalizedValue,
|
||||||
|
setValue,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
buttonDownRef,
|
||||||
|
inputRef,
|
||||||
|
}: Props) {
|
||||||
|
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||||
|
const DEFAULT_STEP = 1;
|
||||||
|
|
||||||
|
function incrementDownPress() {
|
||||||
|
window.clearTimeout(pressTimeout);
|
||||||
|
pressTimeout = setTimeout(() => {
|
||||||
|
buttonDownRef.current = true;
|
||||||
|
pressInterval = setInterval(() => {
|
||||||
|
increment();
|
||||||
|
}, 50);
|
||||||
|
}, PRESS_TRIGGER_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementDownCancel() {
|
||||||
|
buttonDownRef.current = false;
|
||||||
|
window.clearTimeout(pressTimeout);
|
||||||
|
window.clearInterval(pressInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementDownPress() {
|
||||||
|
pressTimeout = setTimeout(() => {
|
||||||
|
buttonDownRef.current = true;
|
||||||
|
pressInterval = setInterval(() => {
|
||||||
|
decrement();
|
||||||
|
}, 50);
|
||||||
|
}, PRESS_TRIGGER_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementDownCancel() {
|
||||||
|
buttonDownRef.current = false;
|
||||||
|
window.clearTimeout(pressTimeout);
|
||||||
|
window.clearInterval(pressInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment() {
|
||||||
|
const existingValue = inputRef.current?.value;
|
||||||
|
const existingNumberValue = twuiNumberfy(existingValue);
|
||||||
|
|
||||||
|
if (max && existingNumberValue >= twuiNumberfy(max)) {
|
||||||
|
return setValue(String(max));
|
||||||
|
} else if (min && existingNumberValue < twuiNumberfy(min)) {
|
||||||
|
return setValue(String(min));
|
||||||
|
} else {
|
||||||
|
setValue(
|
||||||
|
String(
|
||||||
|
existingNumberValue + twuiNumberfy(step || DEFAULT_STEP),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
const existingValue = inputRef.current?.value;
|
||||||
|
const existingNumberValue = twuiNumberfy(existingValue);
|
||||||
|
|
||||||
|
if (min && existingNumberValue <= twuiNumberfy(min)) {
|
||||||
|
setValue(String(min));
|
||||||
|
} else {
|
||||||
|
setValue(
|
||||||
|
String(
|
||||||
|
existingNumberValue - twuiNumberfy(step || DEFAULT_STEP),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
components/form/Input/index.tsx
Normal file
503
components/form/Input/index.tsx
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
InputHTMLAttributes,
|
||||||
|
LabelHTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
|
TextareaHTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Span from "../../layout/Span";
|
||||||
|
import Button from "../../layout/Button";
|
||||||
|
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
|
||||||
|
import { AutocompleteOptions } from "../../types";
|
||||||
|
import twuiNumberfy from "../../utils/numberfy";
|
||||||
|
import Dropdown from "../../elements/Dropdown";
|
||||||
|
import Card from "../../elements/Card";
|
||||||
|
import Stack from "../../layout/Stack";
|
||||||
|
import NumberInputButtons from "./NumberInputButtons";
|
||||||
|
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||||
|
import twuiUseReady from "../../hooks/useReady";
|
||||||
|
import Row from "../../layout/Row";
|
||||||
|
import Paper from "../../elements/Paper";
|
||||||
|
import { TWUISelectValidityObject } from "../Select";
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
let validationFnTimeout: any;
|
||||||
|
let externalValueChangeTimeout: any;
|
||||||
|
|
||||||
|
export type InputProps<KeyType extends string> = Omit<
|
||||||
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
|
"prefix" | "suffix"
|
||||||
|
> &
|
||||||
|
Omit<
|
||||||
|
DetailedHTMLProps<
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
HTMLTextAreaElement
|
||||||
|
>,
|
||||||
|
"prefix" | "suffix"
|
||||||
|
> & {
|
||||||
|
label?: string;
|
||||||
|
variant?: "normal" | "warning" | "error" | "inactive";
|
||||||
|
prefix?: string | ReactNode;
|
||||||
|
suffix?: string | ReactNode;
|
||||||
|
suffixProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
showLabel?: boolean;
|
||||||
|
istextarea?: boolean;
|
||||||
|
wrapperProps?: DetailedHTMLProps<
|
||||||
|
InputHTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||||
|
labelProps?: DetailedHTMLProps<
|
||||||
|
LabelHTMLAttributes<HTMLLabelElement>,
|
||||||
|
HTMLLabelElement
|
||||||
|
>;
|
||||||
|
componentRef?: RefObject<any>;
|
||||||
|
validationRegex?: RegExp;
|
||||||
|
debounce?: number;
|
||||||
|
invalidMessage?: string;
|
||||||
|
validationFunction?: (
|
||||||
|
value: string,
|
||||||
|
element?: HTMLInputElement | HTMLTextAreaElement,
|
||||||
|
) => Promise<TWUISelectValidityObject>;
|
||||||
|
changeHandler?: (value: string) => void;
|
||||||
|
autoComplete?: (typeof AutocompleteOptions)[number];
|
||||||
|
name?: KeyType;
|
||||||
|
valueUpdate?: string;
|
||||||
|
numberText?: boolean;
|
||||||
|
rawNumber?: boolean;
|
||||||
|
setReady?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
decimal?: number;
|
||||||
|
info?: string | ReactNode;
|
||||||
|
ready?: boolean;
|
||||||
|
validity?: TWUISelectValidityObject;
|
||||||
|
clearInputProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let refreshes = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Input Element
|
||||||
|
* @className twui-input
|
||||||
|
* @className twui-input-wrapper
|
||||||
|
* @className twui-input-invalid
|
||||||
|
* @className twui-clear-input-field-button
|
||||||
|
*/
|
||||||
|
export default function Input<KeyType extends string>(
|
||||||
|
inputProps: InputProps<KeyType>,
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
variant,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
componentRef,
|
||||||
|
labelProps,
|
||||||
|
wrapperProps,
|
||||||
|
wrapperWrapperProps,
|
||||||
|
showLabel,
|
||||||
|
istextarea,
|
||||||
|
debounce,
|
||||||
|
invalidMessage: initialInvalidMessage,
|
||||||
|
autoComplete,
|
||||||
|
validationFunction,
|
||||||
|
validationRegex,
|
||||||
|
valueUpdate,
|
||||||
|
numberText,
|
||||||
|
decimal,
|
||||||
|
suffixProps,
|
||||||
|
ready: existingReady,
|
||||||
|
setReady: externalSetReady,
|
||||||
|
info,
|
||||||
|
changeHandler,
|
||||||
|
validity: existingValidity,
|
||||||
|
clearInputProps,
|
||||||
|
rawNumber,
|
||||||
|
...props
|
||||||
|
} = inputProps;
|
||||||
|
|
||||||
|
function getFinalValue(v: any) {
|
||||||
|
if (rawNumber) return twuiNumberfy(v);
|
||||||
|
if (numberText) {
|
||||||
|
return (
|
||||||
|
twuiNumberfy(v, decimal).toLocaleString() +
|
||||||
|
(String(v).match(/\.$/) ? "." : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultInitialValue =
|
||||||
|
props.defaultValue || props.value
|
||||||
|
? getFinalValue(props.defaultValue || props.value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
|
||||||
|
existingValidity || {
|
||||||
|
isValid: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
|
||||||
|
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
const buttonDownRef = React.useRef(false);
|
||||||
|
|
||||||
|
const [value, setValue] = React.useState(
|
||||||
|
props.defaultValue ? String(props.defaultValue) : "",
|
||||||
|
);
|
||||||
|
const [focus, setFocus] = React.useState(false);
|
||||||
|
const [inputType, setInputType] = React.useState(
|
||||||
|
numberText ? "text" : props.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const DEFAULT_DEBOUNCE = 500;
|
||||||
|
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
|
||||||
|
const finalLabel =
|
||||||
|
label ||
|
||||||
|
props.title ||
|
||||||
|
props.placeholder ||
|
||||||
|
(props.name ? twuiSlugToNormalText(props.name) : undefined);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!existingValidity) return;
|
||||||
|
setValidity(existingValidity);
|
||||||
|
}, [existingValidity]);
|
||||||
|
|
||||||
|
const updateValueFn = (val: string) => {
|
||||||
|
if (buttonDownRef.current) return;
|
||||||
|
|
||||||
|
if (changeHandler) {
|
||||||
|
window.clearTimeout(externalValueChangeTimeout);
|
||||||
|
externalValueChangeTimeout = setTimeout(() => {
|
||||||
|
changeHandler(val);
|
||||||
|
}, finalDebounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val == "string") {
|
||||||
|
if (!val.match(/./)) {
|
||||||
|
setValidity({ isValid: true });
|
||||||
|
setValue("");
|
||||||
|
if (istextarea && textAreaRef.current) {
|
||||||
|
textAreaRef.current.value = "";
|
||||||
|
} else if (inputRef?.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (validationRegex) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setValidity({
|
||||||
|
isValid: validationRegex.test(val),
|
||||||
|
msg: "Value mismatch",
|
||||||
|
});
|
||||||
|
}, finalDebounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationFunction) {
|
||||||
|
window.clearTimeout(validationFnTimeout);
|
||||||
|
|
||||||
|
validationFnTimeout = setTimeout(() => {
|
||||||
|
if (validationRegex && !validationRegex.test(val)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validationFunction(val).then((res) => {
|
||||||
|
setValidity(res);
|
||||||
|
});
|
||||||
|
}, finalDebounce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof props.value !== "string" || !props.value.match(/./)) return;
|
||||||
|
setValue(String(props.value));
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (istextarea && textAreaRef.current) {
|
||||||
|
} else if (inputRef?.current) {
|
||||||
|
inputRef.current.value = getFinalValue(value);
|
||||||
|
}
|
||||||
|
updateValueFn(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function handleValueChange(
|
||||||
|
e: React.ChangeEvent<HTMLInputElement> &
|
||||||
|
React.ChangeEvent<HTMLTextAreaElement>,
|
||||||
|
) {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setValue(newValue);
|
||||||
|
props.onChange?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// function updateValue(
|
||||||
|
// v: string,
|
||||||
|
// el?: HTMLInputElement | HTMLTextAreaElement,
|
||||||
|
// ) {
|
||||||
|
// if (istextarea && textAreaRef.current) {
|
||||||
|
// } else if (inputRef?.current) {
|
||||||
|
// inputRef.current.value = getFinalValue(v);
|
||||||
|
// }
|
||||||
|
// updateValueFn(v);
|
||||||
|
// }
|
||||||
|
|
||||||
|
const targetComponent = istextarea ? (
|
||||||
|
<textarea
|
||||||
|
placeholder={
|
||||||
|
props.name ? twuiSlugToNormalText(props.name) : undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full outline-none bg-transparent grow",
|
||||||
|
"twui-textarea",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
ref={textAreaRef}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setFocus(true);
|
||||||
|
props?.onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocus(false);
|
||||||
|
props?.onBlur?.(e);
|
||||||
|
}}
|
||||||
|
onChange={handleValueChange}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
rows={props.height ? Number(props.height) : props.rows || 2}
|
||||||
|
defaultValue={defaultInitialValue}
|
||||||
|
value={props.value ? getFinalValue(props.value) : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
placeholder={
|
||||||
|
props.name ? twuiSlugToNormalText(props.name) : undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full outline-none bg-transparent border-none",
|
||||||
|
"hover:border-none hover:outline-none focus:border-none focus:outline-none",
|
||||||
|
"dark:bg-transparent dark:outline-none dark:border-none",
|
||||||
|
"p-0 grow",
|
||||||
|
"twui-input",
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
ref={inputRef}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setFocus(true);
|
||||||
|
props?.onFocus?.(e);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocus(false);
|
||||||
|
props?.onBlur?.(e);
|
||||||
|
}}
|
||||||
|
onChange={handleValueChange}
|
||||||
|
type={inputType}
|
||||||
|
defaultValue={defaultInitialValue}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
value={props.value ? getFinalValue(props.value) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
|
||||||
|
{...wrapperWrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full gap-1.5 relative z-0 hover:z-100",
|
||||||
|
wrapperWrapperProps?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...wrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
"relative flex items-center gap-2 rounded-default px-3 py-2 outline-1",
|
||||||
|
"hover:[&_.twui-clear-input-field-button]:opacity-100",
|
||||||
|
"w-full border-none",
|
||||||
|
focus && validity.isValid
|
||||||
|
? "outline-slate-700 dark:outline-white/50"
|
||||||
|
: "outline-slate-300 dark:outline-white/20",
|
||||||
|
focus && validity.isValid
|
||||||
|
? "outline-slate-700 dark:outline-white/50"
|
||||||
|
: "outline-slate-300 dark:outline-white/20",
|
||||||
|
variant == "warning" &&
|
||||||
|
validity.isValid &&
|
||||||
|
"outline-yellow-500 dark:outline-yellow-300",
|
||||||
|
variant == "error" &&
|
||||||
|
validity.isValid &&
|
||||||
|
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
|
||||||
|
variant == "inactive" &&
|
||||||
|
validity.isValid &&
|
||||||
|
"opacity-40 pointer-events-none",
|
||||||
|
"bg-white dark:bg-background-dark",
|
||||||
|
validity.isValid
|
||||||
|
? ""
|
||||||
|
: "border-orange-500 outline-orange-500 dark:border-orange-500 dark:outline-orange-500 twui-input-invalid",
|
||||||
|
props.readOnly
|
||||||
|
? props.type == "password"
|
||||||
|
? "opacity-50"
|
||||||
|
: "opacity-50 pointer-events-none"
|
||||||
|
: undefined,
|
||||||
|
"twui-input-wrapper",
|
||||||
|
wrapperProps?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showLabel && (
|
||||||
|
<label
|
||||||
|
htmlFor={props.name}
|
||||||
|
{...labelProps}
|
||||||
|
className={twMerge(
|
||||||
|
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
|
||||||
|
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
|
||||||
|
"overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default",
|
||||||
|
"twui-input-label",
|
||||||
|
labelProps?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{finalLabel}
|
||||||
|
|
||||||
|
{props.required ? (
|
||||||
|
<span className="text-secondary ml-1">*</span>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefix && prefix}
|
||||||
|
|
||||||
|
{targetComponent}
|
||||||
|
|
||||||
|
{props.type == "search" || props.readOnly ? null : (
|
||||||
|
<div
|
||||||
|
title="Clear Input Field"
|
||||||
|
{...clearInputProps}
|
||||||
|
className={twMerge(
|
||||||
|
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer w-7 h-7",
|
||||||
|
"bg-background-light dark:bg-background-dark",
|
||||||
|
"twui-clear-input-field-button",
|
||||||
|
clearInputProps?.className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
if (textAreaRef.current) {
|
||||||
|
textAreaRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("");
|
||||||
|
clearInputProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.type == "password" ? (
|
||||||
|
<div
|
||||||
|
title={
|
||||||
|
inputType == "password"
|
||||||
|
? "View Psasword"
|
||||||
|
: "Hide Password"
|
||||||
|
}
|
||||||
|
className={twMerge("p-1 -my-2 -mx-1")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputType == "password") {
|
||||||
|
setInputType("text");
|
||||||
|
} else {
|
||||||
|
setInputType("password");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inputType == "password" ? (
|
||||||
|
<Eye size={15} />
|
||||||
|
) : (
|
||||||
|
<EyeOff size={15} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{suffix ? suffix : null}
|
||||||
|
|
||||||
|
{numberText ? (
|
||||||
|
<NumberInputButtons
|
||||||
|
setValue={setValue}
|
||||||
|
inputRef={inputRef}
|
||||||
|
getNormalizedValue={getNormalizedValue}
|
||||||
|
max={props.max}
|
||||||
|
min={props.min}
|
||||||
|
step={props.step}
|
||||||
|
buttonDownRef={buttonDownRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{info && (
|
||||||
|
<Dropdown
|
||||||
|
target={
|
||||||
|
<Row className="gap-1">
|
||||||
|
<Info size={12} className="opacity-40" />
|
||||||
|
<Span
|
||||||
|
size="smaller"
|
||||||
|
className="opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
{info}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
openDebounce={700}
|
||||||
|
className="z-1000"
|
||||||
|
hoverOpen
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
className={twMerge(
|
||||||
|
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
|
||||||
|
"max-w-[300px] w-full bg-background-light! dark:bg-background-dark! z-1000",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Stack className="gap-2 items-center">
|
||||||
|
<Row className="gap-1">
|
||||||
|
<InfoIcon size={15} opacity={0.4} />
|
||||||
|
<Span className="text-xs opacity-50">Info</Span>
|
||||||
|
</Row>
|
||||||
|
<Span className="text-center">{info}</Span>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
{!validity.isValid && validity.msg ? (
|
||||||
|
<Span className="text-warning whitespace-nowrap" size="smaller">
|
||||||
|
{validity.msg || "Invalid"}
|
||||||
|
</Span>
|
||||||
|
) : undefined}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
components/form/Radios.tsx
Normal file
79
components/form/Radios.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
components/form/SearchSelect.tsx
Normal file
305
components/form/SearchSelect.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
components/form/Select.tsx
Normal file
252
components/form/Select.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,24 +1,12 @@
|
|||||||
import { DetailedHTMLProps, TextareaHTMLAttributes } from "react";
|
import Input, { InputProps } from "./Input";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Textarea Component
|
* # Textarea Component
|
||||||
* @className twui-textarea
|
* @className twui-textarea
|
||||||
*/
|
*/
|
||||||
export default function Textarea({
|
export default function Textarea<KeyType extends string>({
|
||||||
|
componentRef,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<
|
}: InputProps<KeyType>) {
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
return <Input istextarea {...props} componentRef={componentRef} />;
|
||||||
HTMLTextAreaElement
|
|
||||||
>) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
{...props}
|
|
||||||
className={twMerge(
|
|
||||||
"w-full px-4 py-2 border border-slate-300 rounded",
|
|
||||||
"twui-textarea",
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
components/hooks/useCustomEventDispatch.tsx
Normal file
37
components/hooks/useCustomEventDispatch.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
39
components/hooks/useCustomEventListener.tsx
Normal file
39
components/hooks/useCustomEventListener.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
79
components/hooks/useIntersectionObserver.tsx
Normal file
79
components/hooks/useIntersectionObserver.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Param = {
|
||||||
|
elementRef?: React.RefObject<Element | undefined>;
|
||||||
|
className?: string;
|
||||||
|
elId?: string;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
removeIntersected?: boolean;
|
||||||
|
delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
|
export default function useIntersectionObserver({
|
||||||
|
elementRef,
|
||||||
|
className,
|
||||||
|
options,
|
||||||
|
removeIntersected,
|
||||||
|
delay,
|
||||||
|
elId,
|
||||||
|
}: Param) {
|
||||||
|
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(timeout);
|
||||||
|
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
timeout = 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 };
|
||||||
|
}
|
||||||
31
components/hooks/useLocalStorage.tsx
Normal file
31
components/hooks/useLocalStorage.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
29
components/hooks/useReady.tsx
Normal file
29
components/hooks/useReady.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
35
components/hooks/useStatus.tsx
Normal file
35
components/hooks/useStatus.tsx
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
209
components/hooks/useWebSocket.tsx
Normal file
209
components/hooks/useWebSocket.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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;
|
||||||
|
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.addEventLiatener("wsDataEvent", (e)=>{
|
||||||
|
* console.log(e.detail.data) // type object
|
||||||
|
* })
|
||||||
|
* @example window.addEventLiatener("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_TIMEOUT = 1000 * 60 * 3;
|
||||||
|
|
||||||
|
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||||
|
|
||||||
|
let uptime = 0;
|
||||||
|
let tries = useRef(0);
|
||||||
|
|
||||||
|
// const queue: string[] = [];
|
||||||
|
|
||||||
|
const msgInterval = useRef<any>(null);
|
||||||
|
const sendInterval = useRef<any>(null);
|
||||||
|
const keepAliveInterval = useRef<any>(null);
|
||||||
|
|
||||||
|
const [socket, setSocket] = React.useState<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 domain = window.location.origin;
|
||||||
|
const wsURL = url.startsWith(`ws`)
|
||||||
|
? url
|
||||||
|
: domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/");
|
||||||
|
|
||||||
|
if (!wsURL) return;
|
||||||
|
|
||||||
|
let ws = new WebSocket(wsURL);
|
||||||
|
|
||||||
|
ws.onerror = (ev) => {
|
||||||
|
console.log(`Websocket ERROR:`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
messageQueueRef.current.push(ev.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = (ev) => {
|
||||||
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
|
||||||
|
keepAliveInterval.current = window.setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(KEEP_ALIVE_MESSAGE);
|
||||||
|
}
|
||||||
|
}, KEEP_ALIVE_DURATION);
|
||||||
|
|
||||||
|
setSocket(ws);
|
||||||
|
console.log(`Websocket connected to ${wsURL}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
console.log("Websocket closed!", {
|
||||||
|
code: ev.code,
|
||||||
|
reason: ev.reason,
|
||||||
|
wasClean: ev.wasClean,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disableReconnect) return;
|
||||||
|
|
||||||
|
console.log("Attempting to reconnect ...");
|
||||||
|
console.log("URL:", url);
|
||||||
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
|
||||||
|
console.log("tries", tries);
|
||||||
|
|
||||||
|
if (tries.current >= 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Attempting to reconnect ...");
|
||||||
|
|
||||||
|
tries.current += 1;
|
||||||
|
|
||||||
|
connect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Initial Connection
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (socket) return;
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
|
||||||
|
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
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(() => {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
window.clearInterval(sendInterval.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage = sendMessageQueueRef.current.shift();
|
||||||
|
if (!newMessage) return;
|
||||||
|
|
||||||
|
socket.send(newMessage);
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[socket]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { socket, sendData };
|
||||||
|
}
|
||||||
40
components/hooks/useWebSocketEventHandler.tsx
Normal file
40
components/hooks/useWebSocketEventHandler.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
24
components/hooks/userWindowFocus.tsx
Normal file
24
components/hooks/userWindowFocus.tsx
Normal file
@ -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 };
|
||||||
|
}
|
||||||
25
components/layout/ArrowedLink.tsx
Normal file
25
components/layout/ArrowedLink.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,48 @@
|
|||||||
import {
|
import React, {
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
|
ComponentProps,
|
||||||
DetailedHTMLProps,
|
DetailedHTMLProps,
|
||||||
HTMLAttributeAnchorTarget,
|
HTMLAttributeAnchorTarget,
|
||||||
|
HTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Loading from "../elements/Loading";
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Buttons
|
* # Buttons
|
||||||
@ -17,106 +54,196 @@ import Loading from "../elements/Loading";
|
|||||||
* @className twui-button-secondary
|
* @className twui-button-secondary
|
||||||
* @className twui-button-secondary-outlined
|
* @className twui-button-secondary-outlined
|
||||||
* @className twui-button-secondary-ghost
|
* @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
|
||||||
* @className twui-button-accent-outlined
|
* @className twui-button-accent-outlined
|
||||||
* @className twui-button-accent-ghost
|
* @className twui-button-accent-ghost
|
||||||
* @className twui-button-gray
|
* @className twui-button-gray
|
||||||
* @className twui-button-gray-outlined
|
* @className twui-button-gray-outlined
|
||||||
* @className twui-button-gray-ghost
|
* @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({
|
export default function Button({
|
||||||
href,
|
href,
|
||||||
target,
|
target,
|
||||||
variant,
|
variant,
|
||||||
color,
|
color,
|
||||||
|
size,
|
||||||
|
buttonContentProps,
|
||||||
linkProps,
|
linkProps,
|
||||||
|
beforeIcon,
|
||||||
|
afterIcon,
|
||||||
loading,
|
loading,
|
||||||
|
loadingIconSize,
|
||||||
|
loadingProps,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<
|
}: TWUIButtonProps) {
|
||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
HTMLButtonElement
|
|
||||||
> & {
|
|
||||||
variant?: "normal" | "ghost" | "outlined";
|
|
||||||
color?: "primary" | "secondary" | "accent" | "gray";
|
|
||||||
href?: string;
|
|
||||||
target?: HTMLAttributeAnchorTarget;
|
|
||||||
loading?: boolean;
|
|
||||||
linkProps?: DetailedHTMLProps<
|
|
||||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
||||||
HTMLAnchorElement
|
|
||||||
>;
|
|
||||||
}) {
|
|
||||||
const finalClassName: string = (() => {
|
const finalClassName: string = (() => {
|
||||||
if (variant == "normal" || !variant) {
|
if (variant == "normal" || !variant) {
|
||||||
if (color == "primary" || !color)
|
if (color == "primary" || !color)
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-blue-500 hover:bg-blue-600 text-white",
|
"bg-primary hover:bg-primary-hover text-white",
|
||||||
"twui-button-primary"
|
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white",
|
||||||
|
"twui-button-primary",
|
||||||
);
|
);
|
||||||
if (color == "secondary")
|
if (color == "secondary")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-emerald-500 hover:bg-emerald-600 text-white",
|
"bg-secondary hover:bg-secondary-hover text-white",
|
||||||
"twui-button-secondary"
|
"twui-button-secondary",
|
||||||
|
);
|
||||||
|
if (color == "white")
|
||||||
|
return twMerge(
|
||||||
|
"!bg-white hover:!bg-slate-200 !text-slate-800",
|
||||||
|
"twui-button-white",
|
||||||
);
|
);
|
||||||
if (color == "accent")
|
if (color == "accent")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-violet-500 hover:bg-violet-600 text-white",
|
"bg-accent hover:bg-accent-hover text-white",
|
||||||
"twui-button-accent"
|
"twui-button-accent",
|
||||||
);
|
);
|
||||||
if (color == "gray")
|
if (color == "gray")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-slate-300 hover:bg-slate-200 text-slate-800",
|
"bg-gray hover:bg-gray-hover text-foreground-light",
|
||||||
"twui-button-gray"
|
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
|
||||||
|
"twui-button-gray",
|
||||||
|
);
|
||||||
|
if (color == "success")
|
||||||
|
return twMerge(
|
||||||
|
"bg-success hover:bg-success-hover text-white",
|
||||||
|
"dark:bg-success hover:dark:bg-success-hover text-white",
|
||||||
|
"twui-button-success",
|
||||||
|
);
|
||||||
|
if (color == "error")
|
||||||
|
return twMerge(
|
||||||
|
"bg-error hover:bg-error-hover text-white",
|
||||||
|
"dark:bg-error hover:dark:bg-error-hover text-white",
|
||||||
|
"twui-button-error",
|
||||||
);
|
);
|
||||||
} else if (variant == "outlined") {
|
} else if (variant == "outlined") {
|
||||||
if (color == "primary" || !color)
|
if (color == "primary" || !color)
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline outline-1 outline-blue-500",
|
"bg-transparent outline outline-1 outline-primary",
|
||||||
"text-blue-500",
|
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
|
||||||
"twui-button-primary-outlined"
|
"twui-button-primary-outlined",
|
||||||
);
|
);
|
||||||
if (color == "secondary")
|
if (color == "secondary")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline outline-1 outline-emerald-500",
|
"bg-transparent outline outline-1 outline-secondary",
|
||||||
"text-emerald-500",
|
"text-secondary",
|
||||||
"twui-button-secondary-outlined"
|
"twui-button-secondary-outlined",
|
||||||
);
|
);
|
||||||
if (color == "accent")
|
if (color == "accent")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline outline-1 outline-violet-500",
|
"bg-transparent outline outline-1 outline-accent",
|
||||||
"text-violet-500",
|
"text-accent",
|
||||||
"twui-button-accent-outlined"
|
"twui-button-accent-outlined",
|
||||||
);
|
);
|
||||||
if (color == "gray")
|
if (color == "gray")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline outline-1 outline-slate-300",
|
"bg-transparent outline outline-1 outline-slate-300",
|
||||||
"text-slate-600",
|
"text-slate-600 dark:text-white/60 dark:outline-white/30",
|
||||||
"twui-button-gray-outlined"
|
"twui-button-gray-outlined",
|
||||||
|
);
|
||||||
|
if (color == "white")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline outline-1 outline-white/50",
|
||||||
|
"text-white",
|
||||||
|
"twui-button-white-outlined",
|
||||||
|
);
|
||||||
|
if (color == "error")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline outline-1 outline-error text-error",
|
||||||
|
"dark:outline-error dark:text-error-dark",
|
||||||
|
"twui-button-error-outlined",
|
||||||
);
|
);
|
||||||
} else if (variant == "ghost") {
|
} else if (variant == "ghost") {
|
||||||
if (color == "primary" || !color)
|
if (color == "primary" || !color)
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-blue-500",
|
"text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
|
||||||
"twui-button-primary-ghost"
|
"twui-button-primary-ghost",
|
||||||
);
|
);
|
||||||
if (color == "secondary")
|
if (color == "secondary")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-emerald-500",
|
"text-secondary hover:bg-transparent dark:hover:bg-transparent",
|
||||||
"twui-button-secondary-ghost"
|
"twui-button-secondary-ghost",
|
||||||
|
);
|
||||||
|
if (color == "text")
|
||||||
|
return 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",
|
||||||
);
|
);
|
||||||
if (color == "accent")
|
if (color == "accent")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-violet-500",
|
"text-accent hover:bg-transparent dark:hover:bg-transparent",
|
||||||
"twui-button-accent-ghost"
|
"twui-button-accent-ghost",
|
||||||
);
|
);
|
||||||
if (color == "gray")
|
if (color == "gray")
|
||||||
|
return 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",
|
||||||
|
);
|
||||||
|
if (color == "error")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline-none p-2",
|
"bg-transparent outline-none p-2",
|
||||||
"text-slate-600",
|
"text-red-600 dark:text-red-400",
|
||||||
"twui-button-gray-ghost"
|
"twui-button-error-ghost",
|
||||||
|
);
|
||||||
|
if (color == "warning")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline-none p-2",
|
||||||
|
"text-yellow-600",
|
||||||
|
"twui-button-warning-ghost",
|
||||||
|
);
|
||||||
|
if (color == "success")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline-none p-2",
|
||||||
|
"text-success",
|
||||||
|
"twui-button-success-ghost",
|
||||||
|
);
|
||||||
|
if (color == "white")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline-none p-2",
|
||||||
|
"text-white",
|
||||||
|
"twui-button-white-ghost",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,30 +254,81 @@ export default function Button({
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-blue-600 text-white text-base font-medium px-4 py-2 rounded",
|
"bg-primary text-white font-medium px-4 py-2 rounded-default",
|
||||||
"flex items-center justify-center relative transition-all",
|
"flex items-center justify-center relative transition-all cursor-pointer",
|
||||||
|
props.disabled ? "opacity-40 cursor-not-allowed" : "",
|
||||||
"twui-button-general",
|
"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,
|
finalClassName,
|
||||||
|
loading ? "pointer-events-none opacity-80" : "",
|
||||||
props.className,
|
props.className,
|
||||||
loading ? "pointer-events-none opacity-80" : "l"
|
|
||||||
)}
|
)}
|
||||||
|
aria-label={props.title}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
{...buttonContentProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex items-center gap-2",
|
"flex flex-row items-center gap-2 whitespace-nowrap",
|
||||||
loading ? "opacity-0" : "",
|
loading ? "opacity-0" : "",
|
||||||
"twui-button-content-wrapper"
|
"twui-button-content-wrapper",
|
||||||
|
buttonContentProps?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{beforeIcon ? (
|
||||||
|
typeof beforeIcon == "string" ? (
|
||||||
|
<LucideIcon name={beforeIcon as TWUILucideIconName} />
|
||||||
|
) : (
|
||||||
|
beforeIcon
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
{props.children}
|
{props.children}
|
||||||
|
{afterIcon ? (
|
||||||
|
typeof afterIcon == "string" ? (
|
||||||
|
<LucideIcon name={afterIcon as TWUILucideIconName} />
|
||||||
|
) : (
|
||||||
|
afterIcon
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{loading && <Loading className="absolute" />}
|
|
||||||
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (href)
|
if (href)
|
||||||
return (
|
return (
|
||||||
<a {...linkProps} href={href} target={target}>
|
<a
|
||||||
|
{...linkProps}
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
title={props.title}
|
||||||
|
aria-label={props.title}
|
||||||
|
>
|
||||||
{buttonComponent}
|
{buttonComponent}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
23
components/layout/Center.tsx
Normal file
23
components/layout/Center.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,8 +12,8 @@ export default function Container({
|
|||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex items-center w-full max-w-[1200px] gap-4 justify-between",
|
"flex w-full max-w-container gap-4 justify-between",
|
||||||
"flex-wrap flex-col xl:flex-row",
|
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
|
||||||
"twui-container",
|
"twui-container",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
vertical?: boolean;
|
||||||
|
dashed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Vertical and Horizontal Divider
|
* # Vertical and Horizontal Divider
|
||||||
* @className twui-divider
|
* @className twui-divider
|
||||||
|
* @className twui-divider-horizontal
|
||||||
|
* @className twui-divider-vertical
|
||||||
*/
|
*/
|
||||||
export default function Divider({
|
export default function Divider({ vertical, dashed, ...props }: Props) {
|
||||||
vertical,
|
|
||||||
...props
|
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
|
||||||
vertical?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
@ -20,6 +25,8 @@ export default function Divider({
|
|||||||
? "border-0 border-l h-full min-h-5"
|
? "border-0 border-l h-full min-h-5"
|
||||||
: "border-0 border-t w-full",
|
: "border-0 border-t w-full",
|
||||||
"twui-divider",
|
"twui-divider",
|
||||||
|
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
|
||||||
|
dashed ? "border-dashed" : "border-solid",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
17
components/layout/DocsImg.tsx
Normal file
17
components/layout/DocsImg.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,7 +11,12 @@ export default function H1({
|
|||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-5xl mb-4", "twui-h1", props.className)}
|
className={twMerge(
|
||||||
|
"text-4xl md:text-5xl mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h1",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@ -11,7 +11,12 @@ export default function H2({
|
|||||||
return (
|
return (
|
||||||
<h2
|
<h2
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-3xl mb-4", "twui-h2", props.className)}
|
className={twMerge(
|
||||||
|
"text-2xl md:text-3xl mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h2",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@ -11,7 +11,12 @@ export default function H3({
|
|||||||
return (
|
return (
|
||||||
<h3
|
<h3
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-xl mb-4", "twui-h3", props.className)}
|
className={twMerge(
|
||||||
|
"text-xl mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h3",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@ -11,7 +11,12 @@ export default function H4({
|
|||||||
return (
|
return (
|
||||||
<h4
|
<h4
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-base mb-4", "twui-h4", props.className)}
|
className={twMerge(
|
||||||
|
"text-base mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h4",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@ -11,7 +11,12 @@ export default function H5({
|
|||||||
return (
|
return (
|
||||||
<h5
|
<h5
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-sm mb-4", "twui-h5", props.className)}
|
className={twMerge(
|
||||||
|
"mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h5",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h5>
|
</h5>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export default function HR({
|
|||||||
<hr
|
<hr
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"border-slate-200 dark:border-white/20 w-full my-4",
|
"border-slate-200 dark:border-white/10 w-full my-4",
|
||||||
"twui-hr",
|
"twui-hr",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
38
components/layout/IconLink.tsx
Normal file
38
components/layout/IconLink.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
components/layout/Img.tsx
Normal file
138
components/layout/Img.tsx
Normal file
@ -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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +1,61 @@
|
|||||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from "react";
|
import { AnchorHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
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
|
* # General Anchor Elements
|
||||||
* @className twui-a | twui-anchor
|
* @className twui-a | twui-anchor
|
||||||
|
* @info use `cancel-link` class name to prevent triggering this link from a child element
|
||||||
*/
|
*/
|
||||||
export default function Link({
|
export default function Link({
|
||||||
|
showArrow,
|
||||||
|
arrowSize = 20,
|
||||||
|
arrowProps,
|
||||||
|
strict,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<
|
}: Props) {
|
||||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
||||||
HTMLAnchorElement
|
|
||||||
>) {
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-base text-link-500 no-underline hover:text-link-500/50",
|
"text-link-500 no-underline hover:text-link-500/50",
|
||||||
"text-blue-600 dark:text-blue-400",
|
"text-link dark:text-link-dark hover:opacity-80 transition-all",
|
||||||
"border-0 border-b border-blue-300 border-solid leading-4",
|
"border-0 border-b border-link dark:border-link-dark border-solid leading-4",
|
||||||
"twui-anchor",
|
"twui-anchor",
|
||||||
"twui-a",
|
"twui-a",
|
||||||
props.className
|
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}
|
{props.children}
|
||||||
|
{showArrow && (
|
||||||
|
<ArrowUpRight
|
||||||
|
size={arrowSize}
|
||||||
|
{...arrowProps}
|
||||||
|
className={twMerge(
|
||||||
|
"inline-block ml-1 -mt-[1px]",
|
||||||
|
arrowProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
components/layout/LoadingRectangleBlock.tsx
Normal file
30
components/layout/LoadingRectangleBlock.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,18 +1,24 @@
|
|||||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLHeadingElement>,
|
||||||
|
HTMLHeadingElement
|
||||||
|
> & {
|
||||||
|
noMargin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Paragraph Tag
|
* # Paragraph Tag
|
||||||
* @className twui-p | twui-paragraph
|
* @className twui-p | twui-paragraph
|
||||||
*/
|
*/
|
||||||
export default function P({
|
export default function P({ noMargin, ...props }: Props) {
|
||||||
...props
|
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-base py-4",
|
"py-4",
|
||||||
|
noMargin ? "!m-0 p-0" : "",
|
||||||
"twui-p",
|
"twui-p",
|
||||||
"twui-paragraph",
|
"twui-paragraph",
|
||||||
props.className
|
props.className
|
||||||
|
|||||||
@ -1,18 +1,26 @@
|
|||||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
noWrap?: boolean;
|
||||||
|
itemsStart?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Flexbox Row
|
* # Flexbox Row
|
||||||
* @className twui-row
|
* @className twui-row
|
||||||
*/
|
*/
|
||||||
export default function Row({
|
export default function Row({ noWrap, itemsStart, ...props }: Props) {
|
||||||
...props
|
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-row items-center gap-2 flex-wrap",
|
"flex flex-row gap-2",
|
||||||
|
noWrap ? "xl:flex-nowrap" : "flex-wrap",
|
||||||
|
itemsStart ? "items-start" : "items-center",
|
||||||
"twui-row",
|
"twui-row",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
30
components/layout/Spacer.tsx
Normal file
30
components/layout/Spacer.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,12 +6,29 @@ import { twMerge } from "tailwind-merge";
|
|||||||
* @className twui-span
|
* @className twui-span
|
||||||
*/
|
*/
|
||||||
export default function Span({
|
export default function Span({
|
||||||
|
size,
|
||||||
|
variant,
|
||||||
|
truncate,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement> & {
|
||||||
|
size?: "normal" | "small" | "smaller" | "large" | "larger";
|
||||||
|
variant?: "normal" | "faded";
|
||||||
|
truncate?: { lines?: number; width?: number };
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("text-base", "twui-span", props.className)}
|
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}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
37
components/layout/Stack copy.tsx
Normal file
37
components/layout/Stack copy.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,21 +1,37 @@
|
|||||||
|
import _ from "lodash";
|
||||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
center?: boolean;
|
||||||
|
gap?: number | string;
|
||||||
|
componentRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Flexbox Column
|
* # Flexbox Column
|
||||||
* @className twui-stack
|
* @className twui-stack
|
||||||
*/
|
*/
|
||||||
export default function Stack({
|
export default function Stack({ gap, componentRef, ...props }: Props) {
|
||||||
...props
|
const finalProps = _.omit(props, "center");
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...finalProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col items-start",
|
"flex flex-col items-start gap-4",
|
||||||
|
props.center && "items-center",
|
||||||
|
gap
|
||||||
|
? typeof gap == "string"
|
||||||
|
? `gap-[${gap}]`
|
||||||
|
: `gap-${gap}`
|
||||||
|
: "",
|
||||||
"twui-stack",
|
"twui-stack",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
ref={componentRef}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
106
components/mdx/markdown/MarkdownEditor.tsx
Normal file
106
components/mdx/markdown/MarkdownEditor.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/mdx/markdown/MarkdownEditorComponent.tsx
Normal file
32
components/mdx/markdown/MarkdownEditorComponent.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React, { ComponentProps } from "react";
|
||||||
|
import AceEditor from "../../editors/AceEditor";
|
||||||
|
|
||||||
|
type Props = ComponentProps<typeof AceEditor> & {
|
||||||
|
value: string;
|
||||||
|
setValue: React.Dispatch<any>;
|
||||||
|
maxHeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownEditorComponent({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
maxHeight,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<AceEditor
|
||||||
|
mode="markdown"
|
||||||
|
content={value}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
setValue(newValue);
|
||||||
|
}}
|
||||||
|
wrapperProps={{
|
||||||
|
style: { height: maxHeight },
|
||||||
|
className: `max-h-[${maxHeight}]`,
|
||||||
|
}}
|
||||||
|
placeholder="## Write Some markdown ..."
|
||||||
|
fontSize="14px"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
components/mdx/markdown/MarkdownEditorPreviewComponent.tsx
Normal file
77
components/mdx/markdown/MarkdownEditorPreviewComponent.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React, { ComponentProps, RefObject } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
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 Border from "../../elements/Border";
|
||||||
|
import { useMDXComponents } from "../mdx-components";
|
||||||
|
import EmptyContent from "../../elements/EmptyContent";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
maxHeight: string;
|
||||||
|
wrapperProps?: ComponentProps<typeof Border>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownEditorPreviewComponent({
|
||||||
|
value,
|
||||||
|
maxHeight,
|
||||||
|
wrapperProps,
|
||||||
|
}: Props) {
|
||||||
|
try {
|
||||||
|
const [mdxSource, setMdxSource] =
|
||||||
|
React.useState<
|
||||||
|
MDXRemoteSerializeResult<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>
|
||||||
|
>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const { components } = useMDXComponents();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
// const { data, content } = matter(value);
|
||||||
|
|
||||||
|
const parsedValue = value
|
||||||
|
.replace(/---([^(---)]+)---/, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
serialize(parsedValue, {
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
rehypePlugins: [rehypePrismPlus],
|
||||||
|
},
|
||||||
|
// scope: data,
|
||||||
|
})
|
||||||
|
.then((mdxSrc) => {
|
||||||
|
setMdxSource(mdxSrc);
|
||||||
|
})
|
||||||
|
.catch((err) => {});
|
||||||
|
} catch (error) {}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Border
|
||||||
|
{...wrapperProps}
|
||||||
|
className={twMerge(
|
||||||
|
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
|
||||||
|
"overflow-auto",
|
||||||
|
wrapperProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mdxSource ? (
|
||||||
|
<MDXRemote
|
||||||
|
{...mdxSource}
|
||||||
|
components={{
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Border>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return <EmptyContent title={`Markdown Syntax Error.`} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
components/mdx/markdown/MarkdownEditorSelectorButtons.tsx
Normal file
95
components/mdx/markdown/MarkdownEditorSelectorButtons.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Button from "../../layout/Button";
|
||||||
|
import Border from "../../elements/Border";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
preview: boolean;
|
||||||
|
setPreview: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
sideBySide: boolean;
|
||||||
|
setSideBySide: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownEditorSelectorButtons({
|
||||||
|
preview,
|
||||||
|
setPreview,
|
||||||
|
setSideBySide,
|
||||||
|
sideBySide,
|
||||||
|
}: Props) {
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
|
||||||
|
const defSbsName = "TWUIMarkdownEditorDefaultSideBySide";
|
||||||
|
const defPrevName = "TWUIMarkdownEditorDefaultPreview";
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
|
if (sideBySide) {
|
||||||
|
localStorage.setItem(defSbsName, "true");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(defSbsName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
localStorage.setItem(defPrevName, "true");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(defPrevName);
|
||||||
|
}
|
||||||
|
}, [sideBySide, preview, ready]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (localStorage.getItem(defPrevName)) {
|
||||||
|
setPreview(true);
|
||||||
|
}
|
||||||
|
if (!localStorage.getItem(defSbsName)) {
|
||||||
|
setSideBySide(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setReady(true);
|
||||||
|
}, 200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Border className="p-1.5">
|
||||||
|
<Button
|
||||||
|
title="Code"
|
||||||
|
size="smaller"
|
||||||
|
color={sideBySide ? "gray" : preview ? "gray" : "primary"}
|
||||||
|
variant={
|
||||||
|
sideBySide ? "outlined" : preview ? "outlined" : "normal"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setSideBySide(false);
|
||||||
|
setPreview(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="Preview"
|
||||||
|
size="smaller"
|
||||||
|
color={sideBySide ? "gray" : preview ? "primary" : "gray"}
|
||||||
|
variant={
|
||||||
|
sideBySide ? "outlined" : preview ? "normal" : "outlined"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setSideBySide(false);
|
||||||
|
setPreview(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="Side By Side"
|
||||||
|
size="smaller"
|
||||||
|
color={sideBySide ? "primary" : "gray"}
|
||||||
|
variant={sideBySide ? "normal" : "outlined"}
|
||||||
|
onClick={() => {
|
||||||
|
setSideBySide(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Side By Side
|
||||||
|
</Button>
|
||||||
|
</Border>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/mdx/mdx-components.tsx
Normal file
81
components/mdx/mdx-components.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { MDXComponents } from "mdx/types";
|
||||||
|
import CodeBlock from "../elements/CodeBlock";
|
||||||
|
import H1 from "../layout/H1";
|
||||||
|
import H4 from "../layout/H4";
|
||||||
|
import React from "react";
|
||||||
|
import twuiSlugify from "../utils/slugify";
|
||||||
|
import H2 from "../layout/H2";
|
||||||
|
import P from "../layout/P";
|
||||||
|
import H3 from "../layout/H3";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
components: MDXComponents;
|
||||||
|
codeBgColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMDXComponents(params?: Params) {
|
||||||
|
const codeBgColor = params?.codeBgColor || "#000000";
|
||||||
|
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
h1: ({ children }) => <H1>{children}</H1>,
|
||||||
|
h4: ({ children }) => <H4>{children}</H4>,
|
||||||
|
pre: ({ children, ...props }) => {
|
||||||
|
if (React.isValidElement(children) && children.props) {
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{children.props.children}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{children}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h2: ({ children }) => {
|
||||||
|
const slug = twuiSlugify(children?.toString());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`#${slug}`}
|
||||||
|
id={slug}
|
||||||
|
className="twui-docs-header-anchor"
|
||||||
|
>
|
||||||
|
<H2 className="pt-4 m-0 mb-4 text-2xl">{children}</H2>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
h3: ({ children }) => {
|
||||||
|
const slug = twuiSlugify(children?.toString());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`#${slug}`}
|
||||||
|
id={slug}
|
||||||
|
className="twui-docs-header-anchor"
|
||||||
|
>
|
||||||
|
<H3 className="pt-4 m-0 mb-4 text-xl">{children}</H3>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
img: (props) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
className="w-full h-auto shadow-lg rounded-default overflow-hidden"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
p: ({ children }) => (
|
||||||
|
<P className="mt-2 mb-6 max-w-none py-0">{children}</P>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li className="w-full">{children}</li>,
|
||||||
|
...params?.components,
|
||||||
|
} as MDXComponents,
|
||||||
|
codeBgColor: "rgb(7 12 22)",
|
||||||
|
};
|
||||||
|
}
|
||||||
41
components/next-js/hooks/useMDXComponents.tsx
Normal file
41
components/next-js/hooks/useMDXComponents.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { MDXComponents } from "mdx/types";
|
||||||
|
import H1 from "../../layout/H1";
|
||||||
|
import H2 from "../../layout/H2";
|
||||||
|
import H3 from "../../layout/H3";
|
||||||
|
import H4 from "../../layout/H4";
|
||||||
|
import CodeBlock from "../../elements/CodeBlock";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
components: MDXComponents;
|
||||||
|
codeBgColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useMDXComponents({
|
||||||
|
components,
|
||||||
|
codeBgColor,
|
||||||
|
}: Params): MDXComponents {
|
||||||
|
return {
|
||||||
|
h1: ({ children }) => <H1>{children}</H1>,
|
||||||
|
h4: ({ children }) => <H4>{children}</H4>,
|
||||||
|
pre: ({ children, ...props }) => {
|
||||||
|
if (React.isValidElement(children) && children.props) {
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
{children.props.children}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{children}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
components/package.json
Normal file
31
components/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "tailwind-ui",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/xterm": "latest",
|
||||||
|
"lodash": "latest",
|
||||||
|
"lucide-react": "latest",
|
||||||
|
"react-code-blocks": "latest",
|
||||||
|
"react-responsive-modal": "latest",
|
||||||
|
"tailwind-merge": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"mdx/types": "latest",
|
||||||
|
"gray-matter": "latest",
|
||||||
|
"next-mdx-remote": "latest",
|
||||||
|
"remark-gfm": "latest",
|
||||||
|
"rehype-prism-plus": "latest",
|
||||||
|
"html-to-react": "^1.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ace": "latest",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/lodash": "latest",
|
||||||
|
"@types/node": "latest",
|
||||||
|
"@types/react": "latest",
|
||||||
|
"@types/react-dom": "latest",
|
||||||
|
"postcss": "latest",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"@types/mdx": "latest",
|
||||||
|
"@next/mdx": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user