Updates
This commit is contained in:
parent
dd1d05251d
commit
a0a0ab8ee4
75
components/lib/(functions)/popver/grab-popover-styles.ts
Normal file
75
components/lib/(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;
|
||||||
|
}
|
63
components/lib/(partials)/ModalComponent.tsx
Normal file
63
components/lib/(partials)/ModalComponent.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import React, { DetailedHTMLProps, HTMLAttributes } 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",
|
||||||
|
"twui-modal-root"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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-[500px] 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
|
||||||
|
);
|
||||||
|
}
|
113
components/lib/(partials)/PopoverComponent.tsx
Normal file
113
components/lib/(partials)/PopoverComponent.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
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]",
|
||||||
|
"twui-popover-content",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
style={{ ...style, ...props.style }}
|
||||||
|
onMouseEnter={
|
||||||
|
trigger === "hover" ? popoverEnterFn : props.onMouseEnter
|
||||||
|
}
|
||||||
|
onMouseLeave={
|
||||||
|
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* <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/lib/Readme.md
Normal file
17
components/lib/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.
|
155
components/lib/base.css
Normal file
155
components/lib/base.css
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--breakpoint-xs: 350px;
|
||||||
|
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
193
components/lib/bun.lock
Normal file
193
components/lib/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=="],
|
||||||
|
}
|
||||||
|
}
|
@ -20,7 +20,10 @@ export default function TWUIDocsAside({
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("py-10 hidden xl:flex", props.className)}
|
className={twMerge(
|
||||||
|
"pb-10 hidden xl:flex sticky top-6",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
{before}
|
{before}
|
||||||
|
@ -8,7 +8,7 @@ import Stack from "../../layout/Stack";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import Divider from "../../layout/Divider";
|
import Divider from "../../layout/Divider";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, Circle } from "lucide-react";
|
||||||
import Button from "../../layout/Button";
|
import Button from "../../layout/Button";
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
type Props = DetailedHTMLProps<
|
||||||
@ -20,6 +20,7 @@ type Props = DetailedHTMLProps<
|
|||||||
strict?: boolean;
|
strict?: boolean;
|
||||||
childWrapperProps?: ComponentProps<typeof Stack>;
|
childWrapperProps?: ComponentProps<typeof Stack>;
|
||||||
autoExpandAll?: boolean;
|
autoExpandAll?: boolean;
|
||||||
|
child?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,6 +35,7 @@ export default function TWUIDocsLink({
|
|||||||
childWrapperProps,
|
childWrapperProps,
|
||||||
strict,
|
strict,
|
||||||
autoExpandAll,
|
autoExpandAll,
|
||||||
|
child,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isActive, setIsActive] = React.useState(false);
|
const [isActive, setIsActive] = React.useState(false);
|
||||||
@ -68,16 +70,19 @@ export default function TWUIDocsLink({
|
|||||||
{...wrapperProps}
|
{...wrapperProps}
|
||||||
>
|
>
|
||||||
<Row className="flex-nowrap grow justify-between w-full">
|
<Row className="flex-nowrap grow justify-between w-full">
|
||||||
|
{child && <Circle size={6} />}
|
||||||
<a
|
<a
|
||||||
href={docLink.href}
|
href={docLink.href}
|
||||||
|
title={docLink.title}
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"twui-docs-left-aside-link whitespace-nowrap",
|
"twui-docs-left-aside-link whitespace-nowrap",
|
||||||
"grow",
|
"grow overflow-hidden overflow-ellipsis",
|
||||||
isActive ? "active" : "",
|
isActive ? "active" : "",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
ref={linkRef}
|
ref={linkRef}
|
||||||
|
data-strict={strict || docLink.strict}
|
||||||
>
|
>
|
||||||
{docLink.title}
|
{docLink.title}
|
||||||
</a>
|
</a>
|
||||||
@ -91,6 +96,7 @@ export default function TWUIDocsLink({
|
|||||||
expand ? "rotate-180 opacity-30" : "opacity-70"
|
expand ? "rotate-180 opacity-30" : "opacity-70"
|
||||||
)}
|
)}
|
||||||
onClick={() => setExpand(!expand)}
|
onClick={() => setExpand(!expand)}
|
||||||
|
title="Docs Aside Links Dropdown Button"
|
||||||
>
|
>
|
||||||
<ChevronDown className="text-slate-500" size={20} />
|
<ChevronDown className="text-slate-500" size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -98,19 +104,20 @@ export default function TWUIDocsLink({
|
|||||||
</Row>
|
</Row>
|
||||||
{docLink.children && expand && (
|
{docLink.children && expand && (
|
||||||
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
|
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
|
||||||
<Divider vertical className="h-auto" />
|
|
||||||
<Stack
|
<Stack
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"gap-2 w-full",
|
"gap-2 w-full pl-3",
|
||||||
childWrapperProps?.className
|
childWrapperProps?.className
|
||||||
)}
|
)}
|
||||||
{...childWrapperProps}
|
{...childWrapperProps}
|
||||||
>
|
>
|
||||||
{docLink.children.map((link, index) => (
|
{docLink.children.map((link, index) => (
|
||||||
<TWUIDocsLink
|
<TWUIDocsLink
|
||||||
docLink={link}
|
|
||||||
key={index}
|
key={index}
|
||||||
className="text-sm"
|
docLink={link}
|
||||||
|
className="text-sm opacity-70"
|
||||||
|
autoExpandAll={autoExpandAll}
|
||||||
|
child
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
179
components/lib/composites/docs/TWUIDocsRightAside.tsx
Normal file
179
components/lib/composites/docs/TWUIDocsRightAside.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
console.log("nextElement", nextElement);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -7,9 +7,18 @@ import {
|
|||||||
import Stack from "../../layout/Stack";
|
import Stack from "../../layout/Stack";
|
||||||
import Container from "../../layout/Container";
|
import Container from "../../layout/Container";
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import Divider from "../../layout/Divider";
|
|
||||||
import TWUIDocsAside from "./TWUIDocsAside";
|
import TWUIDocsAside from "./TWUIDocsAside";
|
||||||
import { twMerge } from "tailwind-merge";
|
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 & {
|
type Props = PropsWithChildren & {
|
||||||
DocsLinks: DocsLinkType[];
|
DocsLinks: DocsLinkType[];
|
||||||
@ -22,12 +31,7 @@ type Props = PropsWithChildren & {
|
|||||||
HTMLElement
|
HTMLElement
|
||||||
>;
|
>;
|
||||||
autoExpandAll?: boolean;
|
autoExpandAll?: boolean;
|
||||||
};
|
editPageURL?: string;
|
||||||
|
|
||||||
export type DocsLinkType = {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
children?: DocsLinkType[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,6 +47,7 @@ export default function TWUIDocs({
|
|||||||
docsContentProps,
|
docsContentProps,
|
||||||
leftAsideProps,
|
leftAsideProps,
|
||||||
autoExpandAll,
|
autoExpandAll,
|
||||||
|
editPageURL,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@ -51,25 +56,32 @@ export default function TWUIDocs({
|
|||||||
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
|
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
|
||||||
>
|
>
|
||||||
<Container>
|
<Container>
|
||||||
<Row
|
<Paper className="xl:p-8 mobile-paper-hidden">
|
||||||
{...docsContentProps}
|
<Row
|
||||||
className={twMerge(
|
{...docsContentProps}
|
||||||
"items-stretch gap-6 w-full flex-nowrap",
|
className={twMerge(
|
||||||
docsContentProps?.className
|
"items-start gap-8 w-full flex-nowrap",
|
||||||
)}
|
docsContentProps?.className
|
||||||
>
|
)}
|
||||||
<TWUIDocsAside
|
>
|
||||||
DocsLinks={DocsLinks}
|
<TWUIDocsAside
|
||||||
after={docsAsideAfter}
|
DocsLinks={DocsLinks}
|
||||||
before={docsAsideBefore}
|
after={docsAsideAfter}
|
||||||
autoExpandAll={autoExpandAll}
|
before={docsAsideBefore}
|
||||||
{...leftAsideProps}
|
autoExpandAll={autoExpandAll}
|
||||||
/>
|
{...leftAsideProps}
|
||||||
<Divider vertical className="h-auto hidden xl:flex" />
|
/>
|
||||||
<div className="block twui-docs-content py-10 pl-0 xl:pl-6 grow">
|
<div
|
||||||
{children}
|
className={twMerge(
|
||||||
</div>
|
"block twui-docs-content pl-0 xl:pl-6 grow",
|
||||||
</Row>
|
"overflow-hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<TWUIDocsRightAside editPageURL={editPageURL} />
|
||||||
|
</Row>
|
||||||
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { MutableRefObject } from "react";
|
import React, { MutableRefObject } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import AceEditorModes from "./ace-editor-modes";
|
||||||
|
|
||||||
export type AceEditorComponentType = {
|
export type AceEditorComponentType = {
|
||||||
editorRef?: MutableRefObject<AceAjax.Editor>;
|
editorRef?: MutableRefObject<AceAjax.Editor>;
|
||||||
@ -8,11 +9,16 @@ export type AceEditorComponentType = {
|
|||||||
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
|
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
|
||||||
content?: string;
|
content?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
mode?: any;
|
mode?: (typeof AceEditorModes)[number];
|
||||||
fontSize?: string;
|
fontSize?: string;
|
||||||
previewMode?: boolean;
|
previewMode?: boolean;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
|
wrapperProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
refresh?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout: any;
|
let timeout: any;
|
||||||
@ -34,10 +40,13 @@ export default function AceEditor({
|
|||||||
previewMode,
|
previewMode,
|
||||||
onChange,
|
onChange,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
|
refresh: externalRefresh,
|
||||||
|
wrapperProps,
|
||||||
}: AceEditorComponentType) {
|
}: AceEditorComponentType) {
|
||||||
try {
|
try {
|
||||||
const editorElementRef = React.useRef<HTMLDivElement>();
|
const editorElementRef = React.useRef<HTMLDivElement>(null);
|
||||||
const editorRefInstance = React.useRef<AceAjax.Editor>();
|
// const editorRefInstance = React.useRef<AceAjax.Editor>(null);
|
||||||
|
const editorRefInstance = React.useRef<any>(null);
|
||||||
|
|
||||||
const [refresh, setRefresh] = React.useState(0);
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
const [darkMode, setDarkMode] = React.useState(false);
|
const [darkMode, setDarkMode] = React.useState(false);
|
||||||
@ -58,7 +67,7 @@ export default function AceEditor({
|
|||||||
editor.setOptions({
|
editor.setOptions({
|
||||||
mode: `ace/mode/${mode ? mode : "javascript"}`,
|
mode: `ace/mode/${mode ? mode : "javascript"}`,
|
||||||
theme: darkMode
|
theme: darkMode
|
||||||
? "ace/theme/tomorrow_night_bright"
|
? "ace/theme/tomorrow_night_eighties"
|
||||||
: "ace/theme/ace_light",
|
: "ace/theme/ace_light",
|
||||||
value: content,
|
value: content,
|
||||||
placeholder: placeholder ? placeholder : "",
|
placeholder: placeholder ? placeholder : "",
|
||||||
@ -89,14 +98,17 @@ export default function AceEditor({
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onChange(editor.getValue());
|
onChange(editor.getValue());
|
||||||
console.log(editor.getValue());
|
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
editorRefInstance.current = editor;
|
editorRefInstance.current = editor;
|
||||||
if (editorRef) editorRef.current = editor;
|
if (editorRef) editorRef.current = editor;
|
||||||
}, [refresh, darkMode, ready]);
|
|
||||||
|
return function () {
|
||||||
|
editor.destroy();
|
||||||
|
};
|
||||||
|
}, [refresh, darkMode, ready, externalRefresh]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const htmlClassName = document.documentElement.className;
|
const htmlClassName = document.documentElement.className;
|
||||||
@ -109,10 +121,12 @@ export default function AceEditor({
|
|||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div
|
<div
|
||||||
|
{...wrapperProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-full h-[400px] block rounded-md overflow-hidden",
|
"w-full h-[400px] block rounded-default overflow-hidden",
|
||||||
"border border-slate-200 border-solid",
|
"border border-slate-200 border-solid",
|
||||||
"dark:border-white/20"
|
"dark:border-white/20",
|
||||||
|
wrapperProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import React from "react";
|
import React, { ComponentProps } from "react";
|
||||||
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||||
|
import Border from "../../elements/Border";
|
||||||
|
|
||||||
export type TinyMCEEditorProps = {
|
export type TinyMCEEditorProps<KeyType extends string> = {
|
||||||
tinyMCE?: TinyMCE | null;
|
tinyMCE?: TinyMCE | null;
|
||||||
options?: RawEditorOptions;
|
options?: RawEditorOptions;
|
||||||
editorRef?: React.MutableRefObject<Editor | null>;
|
editorRef?: React.MutableRefObject<Editor | null>;
|
||||||
@ -11,7 +13,17 @@ export type TinyMCEEditorProps = {
|
|||||||
React.HTMLAttributes<HTMLDivElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
|
wrapperWrapperProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
borderProps?: ComponentProps<typeof Border>;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
name?: KeyType;
|
||||||
|
changeHandler?: (content: string) => void;
|
||||||
|
showLabel?: boolean;
|
||||||
|
useParentCSS?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let interval: any;
|
let interval: any;
|
||||||
@ -20,60 +32,158 @@ let interval: any;
|
|||||||
* # Tiny MCE Editor Component
|
* # Tiny MCE Editor Component
|
||||||
* @className_wrapper twui-rte-wrapper
|
* @className_wrapper twui-rte-wrapper
|
||||||
*/
|
*/
|
||||||
export default function TinyMCEEditor({
|
export default function TinyMCEEditor<KeyType extends string>({
|
||||||
options,
|
options,
|
||||||
editorRef,
|
editorRef,
|
||||||
setEditor,
|
setEditor,
|
||||||
tinyMCE,
|
tinyMCE,
|
||||||
wrapperProps,
|
wrapperProps,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
}: TinyMCEEditorProps) {
|
changeHandler,
|
||||||
|
wrapperWrapperProps,
|
||||||
|
borderProps,
|
||||||
|
name,
|
||||||
|
showLabel,
|
||||||
|
useParentCSS,
|
||||||
|
placeholder,
|
||||||
|
}: TinyMCEEditorProps<KeyType>) {
|
||||||
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const FINAL_HEIGHT = options?.height || 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 title = name ? twuiSlugToNormalText(name) : "Rich Text";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!editorComponentRef.current) {
|
const htmlClassName = document.documentElement.className;
|
||||||
|
if (htmlClassName.match(/dark/i)) setDarkMode(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setThemeReady(true);
|
||||||
|
}, 200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!editorComponentRef.current || !themeReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tinyMCE?.init({
|
tinyMCE?.init({
|
||||||
height: FINAL_HEIGHT,
|
height: FINAL_HEIGHT,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
plugins: [
|
plugins:
|
||||||
"advlist lists link image charmap print preview anchor",
|
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
|
||||||
"searchreplace visualblocks code fullscreen",
|
|
||||||
"insertdatetime media table paste code help wordcount",
|
|
||||||
],
|
|
||||||
toolbar:
|
toolbar:
|
||||||
"undo redo | blocks | bold italic | bullist numlist outdent indent | removeformat",
|
"undo redo | blocks | bold italic underline link image | bullist numlist outdent indent | removeformat code searchreplace wordcount preview insertdatetime",
|
||||||
content_style:
|
content_style:
|
||||||
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px }",
|
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
|
||||||
init_instance_callback: (editor) => {
|
init_instance_callback: (editor) => {
|
||||||
setEditor?.(editor as any);
|
setEditor?.(editor as any);
|
||||||
if (editorRef) editorRef.current = editor as any;
|
if (editorRef) editorRef.current = editor as any;
|
||||||
if (defaultValue) editor.setContent(defaultValue);
|
if (defaultValue) editor.setContent(defaultValue);
|
||||||
|
setReady(true);
|
||||||
|
|
||||||
|
editor.on("input", (e) => {
|
||||||
|
changeHandler?.(editor.getContent());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useParentCSS) {
|
||||||
|
useParentStyles(editor);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
base_url: "https://datasquirel.com/tinymce-public",
|
base_url: "https://datasquirel.com/tinymce-public",
|
||||||
body_class: "twui-tinymce",
|
body_class: "twui-tinymce",
|
||||||
|
placeholder,
|
||||||
|
relative_urls: true,
|
||||||
|
remove_script_host: true,
|
||||||
|
convert_urls: false,
|
||||||
...options,
|
...options,
|
||||||
license_key: "gpl",
|
license_key: "gpl",
|
||||||
target: editorComponentRef.current,
|
target: editorComponentRef.current,
|
||||||
|
content_css: darkMode ? "dark" : undefined,
|
||||||
|
skin: darkMode ? "oxide-dark" : undefined,
|
||||||
});
|
});
|
||||||
}, [tinyMCE]);
|
|
||||||
|
return function () {
|
||||||
|
tinyMCE?.remove();
|
||||||
|
};
|
||||||
|
}, [tinyMCE, themeReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...wrapperProps}
|
{...wrapperWrapperProps}
|
||||||
ref={editorComponentRef}
|
|
||||||
style={{
|
|
||||||
height: FINAL_HEIGHT + "px",
|
|
||||||
...wrapperProps?.style,
|
|
||||||
}}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-slate-200 dark:bg-slate-700 rounded-sm",
|
"relative w-full [&_.tox-tinymce]:!border-none",
|
||||||
"twui-rte-wrapper"
|
"bg-background-light dark:bg-background-dark",
|
||||||
|
wrapperWrapperProps?.className
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
{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={name || "twui-tinymce"}
|
||||||
|
>
|
||||||
|
{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={name || "twui-tinymce"}
|
||||||
|
></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
125
components/lib/editors/ace-editor-modes.ts
Normal file
125
components/lib/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;
|
@ -17,8 +17,8 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
|
|||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative flex items-center gap-2 border border-solid rounded",
|
"relative flex items-center gap-2 border border-solid rounded-default",
|
||||||
"border-slate-300 dark:border-white/10",
|
"border-slate-200 dark:border-white/10",
|
||||||
spacing
|
spacing
|
||||||
? spacing == "normal"
|
? spacing == "normal"
|
||||||
? "px-3 py-2"
|
? "px-3 py-2"
|
||||||
|
@ -1,60 +1,63 @@
|
|||||||
import React from "react";
|
import React, { ComponentProps, ReactNode } from "react";
|
||||||
import Link from "../layout/Link";
|
import Link from "../layout/Link";
|
||||||
import Divider from "../layout/Divider";
|
import Divider from "../layout/Divider";
|
||||||
import Row from "../layout/Row";
|
import Row from "../layout/Row";
|
||||||
import lowerToTitleCase from "../utils/lower-to-title-case";
|
import lowerToTitleCase from "../utils/lower-to-title-case";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import Button from "../layout/Button";
|
||||||
|
|
||||||
type LinkObject = {
|
type LinkObject = {
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
excludeRegexMatch?: RegExp;
|
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
|
* # TWUI Breadcrumbs
|
||||||
* @className `twui-current-breadcrumb-link`
|
* @className `twui-breadcrumb-link`
|
||||||
* @className `twui-current-breadcrumb-wrapper`
|
* @className `twui-current-breadcrumb-wrapper`
|
||||||
|
* @className `twui-breadcrumbs-divider`
|
||||||
*/
|
*/
|
||||||
export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
export default function Breadcrumbs({
|
||||||
const [links, setLinks] = React.useState<LinkObject[] | null>(null);
|
excludeRegexMatch,
|
||||||
const [current, setCurrent] = React.useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
|
if (links) return;
|
||||||
|
|
||||||
let pathname = window.location.pathname;
|
let pathname = window.location.pathname;
|
||||||
let pathLinks = pathname.split("/");
|
|
||||||
|
|
||||||
let validPathLinks = [];
|
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
|
||||||
|
url: pathname,
|
||||||
validPathLinks.push({
|
excludeRegexMatch,
|
||||||
title: "Home",
|
skipHome,
|
||||||
path: pathname.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;
|
|
||||||
})(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setLinks(validPathLinks);
|
setLinks(validPathLinks);
|
||||||
@ -69,13 +72,48 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<nav
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"overflow-x-auto max-w-[70vw]",
|
"overflow-x-auto",
|
||||||
"twui-current-breadcrumb-wrapper"
|
"twui-current-breadcrumb-wrapper"
|
||||||
)}
|
)}
|
||||||
|
aria-label="Breadcrumb"
|
||||||
>
|
>
|
||||||
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
|
<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",
|
||||||
|
backButtonProps?.className
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
window.history.back();
|
||||||
|
backButtonProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
title="Breadcrumbs Back Button"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</Button>
|
||||||
|
{divider || (
|
||||||
|
<Divider
|
||||||
|
vertical
|
||||||
|
className={twMerge(
|
||||||
|
"twui-breadcrumbs-divider",
|
||||||
|
dividerProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
{links.map((linkObject, index, array) => {
|
{links.map((linkObject, index, array) => {
|
||||||
const isTarget = array.length - 1 == index;
|
const isTarget = array.length - 1 == index;
|
||||||
|
|
||||||
@ -84,13 +122,21 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
|||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={linkObject.path}
|
href={linkObject.path}
|
||||||
|
{...linkProps}
|
||||||
|
{...(isTarget ? currentLinkProps : {})}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-slate-400 dark:text-slate-500 pointer-events-none text-xs",
|
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
|
||||||
|
"max-w-[200px] text-ellipsis overflow-hidden",
|
||||||
isTarget ? "current" : "",
|
isTarget ? "current" : "",
|
||||||
"twui-current-breadcrumb-link"
|
"twui-breadcrumb-link",
|
||||||
|
linkProps?.className,
|
||||||
|
isTarget && currentLinkProps?.className
|
||||||
)}
|
)}
|
||||||
|
title={
|
||||||
|
currentLinkProps?.title || linkObject.title
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{linkObject.title}
|
{currentTitle || linkObject.title}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -98,30 +144,84 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
|||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<Link
|
<Link
|
||||||
href={linkObject.path}
|
href={linkObject.path}
|
||||||
|
{...linkProps}
|
||||||
|
{...(isTarget ? currentLinkProps : {})}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-xs",
|
"text-xs",
|
||||||
isTarget ? "current" : "",
|
isTarget ? "current" : "",
|
||||||
"twui-current-breadcrumb-link"
|
"twui-breadcrumb-link",
|
||||||
|
linkProps?.className,
|
||||||
|
isTarget && currentLinkProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{linkObject.title}
|
{currentLinkProps?.title ||
|
||||||
|
linkObject.title}
|
||||||
</Link>
|
</Link>
|
||||||
<Divider vertical />
|
{divider || (
|
||||||
|
<Divider
|
||||||
|
vertical
|
||||||
|
{...dividerProps}
|
||||||
|
className={twMerge(
|
||||||
|
"twui-breadcrumbs-divider",
|
||||||
|
dividerProps?.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Link from "../layout/Link";
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
type Props = DetailedHTMLProps<
|
||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
@ -7,11 +12,10 @@ type Props = DetailedHTMLProps<
|
|||||||
> & {
|
> & {
|
||||||
variant?: "normal";
|
variant?: "normal";
|
||||||
href?: string;
|
href?: string;
|
||||||
linkProps?: DetailedHTMLProps<
|
linkProps?: ComponentProps<typeof Link>;
|
||||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
||||||
HTMLAnchorElement
|
|
||||||
>;
|
|
||||||
noHover?: boolean;
|
noHover?: boolean;
|
||||||
|
elRef?: React.RefObject<HTMLDivElement>;
|
||||||
|
linkRef?: React.RefObject<HTMLAnchorElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,20 +31,18 @@ export default function Card({
|
|||||||
variant,
|
variant,
|
||||||
linkProps,
|
linkProps,
|
||||||
noHover,
|
noHover,
|
||||||
|
elRef,
|
||||||
|
linkRef,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const component = (
|
const component = (
|
||||||
<div
|
<div
|
||||||
|
ref={elRef}
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
|
"flex flex-row items-center p-4 rounded-default bg-white dark:bg-white/10",
|
||||||
"border border-slate-200 dark:border-white/10 border-solid",
|
"border border-slate-200 dark:border-white/10 border-solid",
|
||||||
noHover
|
noHover ? "" : "twui-card",
|
||||||
? ""
|
|
||||||
: href
|
|
||||||
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
|
|
||||||
: "",
|
|
||||||
"twui-card",
|
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -50,28 +52,19 @@ export default function Card({
|
|||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
|
ref={linkRef}
|
||||||
href={href}
|
href={href}
|
||||||
{...linkProps}
|
{...linkProps}
|
||||||
onClick={(e) => {
|
|
||||||
const targetEl = e.target as HTMLElement;
|
|
||||||
if (targetEl.closest(".nested-link")) {
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (e.ctrlKey) {
|
|
||||||
window.open(href, "_blank");
|
|
||||||
} else {
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
linkProps?.onClick?.(e);
|
|
||||||
}}
|
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
|
"twui-card",
|
||||||
"twui-card-link",
|
"twui-card-link",
|
||||||
linkProps?.className
|
linkProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{component}
|
{component}
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
61
components/lib/elements/CheckBulletPoints.tsx
Normal file
61
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
@ -40,7 +40,7 @@ export default function CodeBlock({
|
|||||||
language,
|
language,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const codeRef = React.useRef<HTMLDivElement>();
|
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
@ -52,9 +52,9 @@ export default function CodeBlock({
|
|||||||
<div
|
<div
|
||||||
{...wrapperProps}
|
{...wrapperProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"outline outline-[1px] outline-slate-200 dark:outline-white/10",
|
"outline-[1px] outline-slate-200 dark:outline-white/10",
|
||||||
`rounded w-full transition-all items-start`,
|
`rounded w-full transition-all items-start`,
|
||||||
"relative",
|
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||||
"twui-code-block-wrapper",
|
"twui-code-block-wrapper",
|
||||||
wrapperProps?.className
|
wrapperProps?.className
|
||||||
)}
|
)}
|
||||||
@ -62,7 +62,6 @@ export default function CodeBlock({
|
|||||||
boxShadow: copied
|
boxShadow: copied
|
||||||
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||||
: undefined,
|
: undefined,
|
||||||
maxWidth: "calc(100vw - 80px)",
|
|
||||||
backgroundColor: finalBackgroundColor,
|
backgroundColor: finalBackgroundColor,
|
||||||
...props.style,
|
...props.style,
|
||||||
}}
|
}}
|
||||||
@ -99,7 +98,7 @@ export default function CodeBlock({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="gray"
|
color="gray"
|
||||||
beforeIcon={<Copy size={17} color="white" />}
|
beforeIcon={<Copy size={17} color="white" />}
|
||||||
className="!p-1 !bg-transparent"
|
className="!p-1 !bg-transparent opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const content =
|
const content =
|
||||||
codeRef.current?.textContent;
|
codeRef.current?.textContent;
|
||||||
@ -136,7 +135,7 @@ export default function CodeBlock({
|
|||||||
<pre
|
<pre
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"!my-0",
|
"!my-0 whitespace-pre-wrap",
|
||||||
language ? `language-${language}` : "",
|
language ? `language-${language}` : "",
|
||||||
"twui-code-block-pre",
|
"twui-code-block-pre",
|
||||||
props.className
|
props.className
|
||||||
|
@ -1,26 +1,71 @@
|
|||||||
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
|
* # Color Scheme Loader
|
||||||
* @className_wrapper twui-color-scheme-selector
|
* @className_wrapper twui-color-scheme-selector
|
||||||
*/
|
*/
|
||||||
export default function ColorSchemeSelector({
|
export default function ColorSchemeSelector({
|
||||||
active,
|
active: initialActive,
|
||||||
setActive,
|
setActive: externalSetActive,
|
||||||
toggleProps,
|
iconWrapperProps,
|
||||||
|
defaultScheme,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
}: Props) {
|
||||||
toggleProps?: TWUI_TOGGLE_PROPS;
|
const [active, setActive] = React.useState(initialActive);
|
||||||
active: boolean;
|
|
||||||
setActive: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}) {
|
|
||||||
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]);
|
||||||
|
|
||||||
@ -33,7 +78,24 @@ export default function ColorSchemeSelector({
|
|||||||
props.className
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,11 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
export const TWUIDropdownContentPositions = [
|
export const TWUIDropdownContentPositions = [
|
||||||
"left",
|
"left",
|
||||||
|
"bottom-left",
|
||||||
|
"top-left",
|
||||||
"right",
|
"right",
|
||||||
|
"bottom-right",
|
||||||
|
"top-right",
|
||||||
"center",
|
"center",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -23,15 +27,17 @@ export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
debounce?: number;
|
debounce?: number;
|
||||||
|
openDebounce?: number;
|
||||||
hoverOpen?: boolean;
|
hoverOpen?: boolean;
|
||||||
above?: boolean;
|
above?: boolean;
|
||||||
position?: (typeof TWUIDropdownContentPositions)[number];
|
position?: (typeof TWUIDropdownContentPositions)[number];
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
externalOpen?: boolean;
|
||||||
|
keepOpen?: boolean;
|
||||||
|
disableClickActions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout: any;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Toggle Component
|
* # Toggle Component
|
||||||
* @className_wrapper twui-dropdown-wrapper
|
* @className_wrapper twui-dropdown-wrapper
|
||||||
@ -45,16 +51,24 @@ export default function Dropdown({
|
|||||||
targetWrapperProps,
|
targetWrapperProps,
|
||||||
hoverOpen,
|
hoverOpen,
|
||||||
above,
|
above,
|
||||||
debounce = 500,
|
debounce = 200,
|
||||||
|
openDebounce = 200,
|
||||||
target,
|
target,
|
||||||
position = "center",
|
position = "center",
|
||||||
topOffset,
|
topOffset,
|
||||||
externalSetOpen,
|
externalSetOpen,
|
||||||
|
keepOpen,
|
||||||
|
disableClickActions,
|
||||||
|
externalOpen,
|
||||||
...props
|
...props
|
||||||
}: TWUI_DROPDOWN_PROPS) {
|
}: TWUI_DROPDOWN_PROPS) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(externalOpen);
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
let openTimeout: any;
|
||||||
|
|
||||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||||
const targetEl = e.target as HTMLElement;
|
const targetEl = e.target as HTMLElement;
|
||||||
@ -71,12 +85,17 @@ export default function Dropdown({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (keepOpen) return;
|
||||||
document.addEventListener("click", handleClickOutside);
|
document.addEventListener("click", handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", handleClickOutside);
|
document.removeEventListener("click", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setOpen(externalOpen);
|
||||||
|
}, [externalOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
@ -88,11 +107,18 @@ export default function Dropdown({
|
|||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!hoverOpen) return;
|
if (!hoverOpen) return;
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
externalSetOpen?.(true);
|
window.clearTimeout(openTimeout);
|
||||||
setOpen(true);
|
|
||||||
|
openTimeout = setTimeout(() => {
|
||||||
|
externalSetOpen?.(true);
|
||||||
|
setOpen(true);
|
||||||
|
}, openDebounce);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={(e) => {
|
||||||
if (!hoverOpen) return;
|
if (!hoverOpen) return;
|
||||||
|
|
||||||
|
window.clearTimeout(openTimeout);
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
externalSetOpen?.(false);
|
externalSetOpen?.(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@ -107,6 +133,7 @@ export default function Dropdown({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const targetEl = e.target as HTMLElement | null;
|
const targetEl = e.target as HTMLElement | null;
|
||||||
if (targetEl?.closest?.(".cancel-link")) return;
|
if (targetEl?.closest?.(".cancel-link")) return;
|
||||||
|
if (disableClickActions) return;
|
||||||
externalSetOpen?.(!open);
|
externalSetOpen?.(!open);
|
||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
}}
|
}}
|
||||||
@ -122,12 +149,18 @@ export default function Dropdown({
|
|||||||
<div
|
<div
|
||||||
{...contentWrapperProps}
|
{...contentWrapperProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"absolute z-10",
|
"absolute z-10 mt-1",
|
||||||
position == "left"
|
position == "left"
|
||||||
? "left-0"
|
? "left-[100%] top-[50%] -translate-y-[50%]"
|
||||||
: position == "right"
|
: position == "right"
|
||||||
? "right-0"
|
? "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%]"
|
||||||
|
: "top-[100%]",
|
||||||
above ? "-translate-y-[120%]" : "",
|
above ? "-translate-y-[120%]" : "",
|
||||||
open ? "flex" : "hidden",
|
open ? "flex" : "hidden",
|
||||||
"twui-dropdown-content",
|
"twui-dropdown-content",
|
||||||
@ -142,9 +175,10 @@ export default function Dropdown({
|
|||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
top: `calc(100% + ${topOffset || 0}px)`,
|
// top: `calc(100% + ${topOffset || 0}px)`,
|
||||||
...contentWrapperProps?.style,
|
...contentWrapperProps?.style,
|
||||||
}}
|
}}
|
||||||
|
ref={dropdownContentRef}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
88
components/lib/elements/EmptyContent.tsx
Normal file
88
components/lib/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/lib/elements/HeaderLink.tsx
Normal file
33
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
97
components/lib/elements/HeaderNav.tsx
Normal file
97
components/lib/elements/HeaderNav.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
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 === "/") {
|
||||||
|
} 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/lib/elements/HeaderNavLinkComponent.tsx
Normal file
141
components/lib/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="bottom-right"
|
||||||
|
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/lib/elements/HtmlToReactComponent.tsx
Normal file
34
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
157
components/lib/elements/LinkList.tsx
Normal file
157
components/lib/elements/LinkList.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
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;
|
||||||
|
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",
|
||||||
|
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.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
|
||||||
|
)}
|
||||||
|
strict={link.strict}
|
||||||
|
onClick={(e) => {
|
||||||
|
link.onClick?.(e);
|
||||||
|
link.linkProps?.onClick?.(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row>
|
||||||
|
{!link.iconPosition ||
|
||||||
|
link.iconPosition == "before"
|
||||||
|
? link.icon
|
||||||
|
: null}
|
||||||
|
{link.title}
|
||||||
|
{link.iconPosition == "after"
|
||||||
|
? link.icon
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
</Link>
|
||||||
|
{finalDivider}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -35,8 +35,8 @@ export default function Loading({ size, svgClassName, ...props }: Props) {
|
|||||||
<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",
|
||||||
"twui-loading",
|
"dark:fill-white twui-loading",
|
||||||
sizeClassName,
|
sizeClassName,
|
||||||
svgClassName
|
svgClassName
|
||||||
)}
|
)}
|
||||||
|
33
components/lib/elements/LoadingOverlay.tsx
Normal file
33
components/lib/elements/LoadingOverlay.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Center from "../layout/Center";
|
||||||
|
import Loading from "./Loading";
|
||||||
|
|
||||||
|
type Props = DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
> & {
|
||||||
|
loadingProps?: ComponentProps<typeof Loading>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Loading Overlay Component
|
||||||
|
* @className_wrapper twui-loading-overlay
|
||||||
|
*/
|
||||||
|
export default function LoadingOverlay({ loadingProps, ...props }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"absolute top-0 left-0 w-full h-full z-[500]",
|
||||||
|
"bg-background-light/90 dark:bg-background-dark/90",
|
||||||
|
props.className,
|
||||||
|
"twui-loading-overlay"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Center>
|
||||||
|
<Loading {...loadingProps} />
|
||||||
|
</Center>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,74 +1,181 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
|
import ModalComponent from "../(partials)/ModalComponent";
|
||||||
|
import PopoverComponent from "../(partials)/PopoverComponent";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import Paper from "./Paper";
|
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
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>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
target: React.ReactNode;
|
target?: React.ReactNode;
|
||||||
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Modal Component
|
* # Modal Component
|
||||||
* @className_wrapper twui-modal-root
|
* @ID twui-modal-root
|
||||||
* @className_wrapper twui-modal
|
* @className twui-modal-content
|
||||||
|
* @className twui-modal
|
||||||
|
* @ID twui-popover-root
|
||||||
|
* @className twui-popover-content
|
||||||
|
* @className twui-popover-target
|
||||||
*/
|
*/
|
||||||
export default function Modal({ target, targetRef, ...props }: Props) {
|
export default function Modal(props: TWUI_MODAL_PROPS) {
|
||||||
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
|
const {
|
||||||
|
target,
|
||||||
|
targetRef,
|
||||||
|
targetWrapperProps,
|
||||||
|
open: existingOpen,
|
||||||
|
setOpen: existingSetOpen,
|
||||||
|
isPopover,
|
||||||
|
popoverReferenceRef,
|
||||||
|
trigger = "hover",
|
||||||
|
debounce = 500,
|
||||||
|
onClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const [open, setOpen] = React.useState(existingOpen || false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const wrapperEl = document.createElement("div");
|
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
|
||||||
|
const modalRoot = document.getElementById(IDName);
|
||||||
|
|
||||||
wrapperEl.className = twMerge(
|
if (modalRoot) {
|
||||||
"fixed z-[200000] top-0 left-0 w-screen h-screen",
|
setReady(true);
|
||||||
"flex flex-col items-center justify-center",
|
} else {
|
||||||
"twui-modal-root"
|
const newModalRootEl = document.createElement("div");
|
||||||
);
|
newModalRootEl.id = IDName;
|
||||||
|
document.body.appendChild(newModalRootEl);
|
||||||
setWrapper(wrapperEl);
|
setReady(true);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modalEl = (
|
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>
|
<React.Fragment>
|
||||||
<div
|
{target ? (
|
||||||
className={twMerge(
|
<div
|
||||||
"absolute top-0 left-0 bg-slate-900/80 z-0",
|
{...targetWrapperProps}
|
||||||
"w-screen h-screen"
|
onClick={(e) => setOpen(!open)}
|
||||||
)}
|
ref={finalTargetRef}
|
||||||
onClick={(e) => {
|
onMouseEnter={
|
||||||
closeModal({ wrapperEl: wrapper });
|
isPopover && trigger === "hover"
|
||||||
}}
|
? popoverEnterFn
|
||||||
></div>
|
: targetWrapperProps?.onMouseEnter
|
||||||
<Paper
|
}
|
||||||
{...props}
|
onMouseLeave={
|
||||||
className={twMerge("z-10 max-w-[500px]", props.className)}
|
isPopover && trigger === "hover"
|
||||||
>
|
? popoverLeaveFn
|
||||||
{props.children}
|
: targetWrapperProps?.onMouseLeave
|
||||||
</Paper>
|
}
|
||||||
|
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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetEl = (
|
|
||||||
<div
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!wrapper) return;
|
|
||||||
document.body.appendChild(wrapper);
|
|
||||||
const root = createRoot(wrapper);
|
|
||||||
root.render(modalEl);
|
|
||||||
}}
|
|
||||||
ref={targetRef as any}
|
|
||||||
>
|
|
||||||
{target}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return targetEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
|
|
||||||
if (!wrapperEl) return;
|
|
||||||
wrapperEl.parentElement?.removeChild(wrapperEl);
|
|
||||||
}
|
}
|
||||||
|
126
components/lib/elements/Pagination.tsx
Normal file
126
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -8,6 +8,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export default function Paper({
|
export default function Paper({
|
||||||
variant,
|
variant,
|
||||||
linkProps,
|
linkProps,
|
||||||
|
componentRef,
|
||||||
...props
|
...props
|
||||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||||
variant?: "normal";
|
variant?: "normal";
|
||||||
@ -15,13 +16,16 @@ export default function Paper({
|
|||||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
HTMLAnchorElement
|
HTMLAnchorElement
|
||||||
>;
|
>;
|
||||||
|
componentRef?: RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={componentRef as any}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
|
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
|
||||||
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||||
|
"relative",
|
||||||
"twui-paper",
|
"twui-paper",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
9
components/lib/elements/Popover.tsx
Normal file
9
components/lib/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 />;
|
||||||
|
}
|
@ -3,11 +3,7 @@ import Input, { InputProps } from "../form/Input";
|
|||||||
import Button from "../layout/Button";
|
import Button from "../layout/Button";
|
||||||
import Row from "../layout/Row";
|
import Row from "../layout/Row";
|
||||||
import { Search as SearchIcon } from "lucide-react";
|
import { Search as SearchIcon } from "lucide-react";
|
||||||
import React, {
|
import React, { DetailedHTMLProps } from "react";
|
||||||
DetailedHTMLProps,
|
|
||||||
InputHTMLAttributes,
|
|
||||||
TextareaHTMLAttributes,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
let timeout: any;
|
let timeout: any;
|
||||||
|
|
||||||
@ -16,12 +12,15 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
dispatch?: (value?: string) => void;
|
dispatch?: (value?: string) => void;
|
||||||
|
changeHandler?: (value?: string) => void;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
inputProps?: InputProps<KeyType>;
|
inputProps?: InputProps<KeyType>;
|
||||||
buttonProps?: DetailedHTMLProps<
|
buttonProps?: DetailedHTMLProps<
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
>;
|
>;
|
||||||
|
loading?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,9 +31,12 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
|||||||
*/
|
*/
|
||||||
export default function Search<KeyType extends string>({
|
export default function Search<KeyType extends string>({
|
||||||
dispatch,
|
dispatch,
|
||||||
|
changeHandler,
|
||||||
delay = 500,
|
delay = 500,
|
||||||
inputProps,
|
inputProps,
|
||||||
buttonProps,
|
buttonProps,
|
||||||
|
loading,
|
||||||
|
placeholder,
|
||||||
...props
|
...props
|
||||||
}: SearchProps<KeyType>) {
|
}: SearchProps<KeyType>) {
|
||||||
const [input, setInput] = React.useState("");
|
const [input, setInput] = React.useState("");
|
||||||
@ -44,10 +46,11 @@ export default function Search<KeyType extends string>({
|
|||||||
|
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
dispatch?.(input);
|
dispatch?.(input);
|
||||||
|
changeHandler?.(input);
|
||||||
}, delay);
|
}, delay);
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>();
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (props.autoFocus) {
|
if (props.autoFocus) {
|
||||||
@ -66,7 +69,7 @@ export default function Search<KeyType extends string>({
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search"
|
placeholder={placeholder || "Search"}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@ -81,17 +84,21 @@ export default function Search<KeyType extends string>({
|
|||||||
componentRef={inputRef}
|
componentRef={inputRef}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
loadingProps={{ size: "small" }}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="gray"
|
color="gray"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"rounded-l-none my-[1px]",
|
"rounded-l-none ml-[1px]",
|
||||||
"twui-search-button",
|
"twui-search-button",
|
||||||
buttonProps?.className
|
buttonProps?.className
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch?.(input);
|
dispatch?.(input);
|
||||||
|
changeHandler?.(input);
|
||||||
}}
|
}}
|
||||||
|
title="Search Button"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
<SearchIcon
|
<SearchIcon
|
||||||
className="text-slate-800 dark:text-white"
|
className="text-slate-800 dark:text-white"
|
||||||
|
76
components/lib/elements/Table.tsx
Normal file
76
components/lib/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 text-sm 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-sm text-foreground-light",
|
||||||
|
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
|
||||||
|
"overflow-ellipsis"
|
||||||
|
)}
|
||||||
|
title={row[header]}
|
||||||
|
>
|
||||||
|
{row[header]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,19 +1,19 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Border from "./Border";
|
import Border from "./Border";
|
||||||
import Stack from "../layout/Stack";
|
import Stack from "../layout/Stack";
|
||||||
import Row from "../layout/Row";
|
import Row from "../layout/Row";
|
||||||
import Span from "../layout/Span";
|
import twuiSlugify from "../utils/slugify";
|
||||||
|
|
||||||
export type TWUITabsObject = {
|
export type TWUITabsObject = {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value?: string;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
defaultActive?: boolean;
|
defaultActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||||
tabsContentArray: TWUITabsObject[];
|
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
|
||||||
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||||
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
@ -21,6 +21,11 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
|||||||
>;
|
>;
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
debounce?: number;
|
debounce?: number;
|
||||||
|
/**
|
||||||
|
* React Component to display when switching
|
||||||
|
*/
|
||||||
|
switchComponent?: ReactNode;
|
||||||
|
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,20 +41,37 @@ export default function Tabs({
|
|||||||
tabsButtonsWrapperProps,
|
tabsButtonsWrapperProps,
|
||||||
centered,
|
centered,
|
||||||
debounce = 100,
|
debounce = 100,
|
||||||
|
switchComponent,
|
||||||
|
setActiveValue: existingSetActiveValue,
|
||||||
...props
|
...props
|
||||||
}: TWUI_TOGGLE_PROPS) {
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
const values = tabsContentArray.map((obj) => obj.value);
|
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(
|
const [activeValue, setActiveValue] = React.useState(
|
||||||
tabsContentArray.find((ctn) => ctn.defaultActive)?.value ||
|
defaultActiveObj
|
||||||
values[0] ||
|
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||||
undefined
|
: values[0] || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetContent = tabsContentArray.find(
|
const targetContent = finalTabsContentArray.find(
|
||||||
(ctn) => ctn.value == activeValue
|
(ctn) =>
|
||||||
|
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
existingSetActiveValue?.(activeValue);
|
||||||
|
}, [activeValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
{...props}
|
{...props}
|
||||||
@ -63,16 +85,21 @@ export default function Tabs({
|
|||||||
tabsButtonsWrapperProps?.className
|
tabsButtonsWrapperProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Border className="p-0 w-full" {...tabsBorderProps}>
|
<Border
|
||||||
|
className="p-0 w-full overflow-hidden"
|
||||||
|
{...tabsBorderProps}
|
||||||
|
>
|
||||||
<Row
|
<Row
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"gap-0 items-stretch w-full",
|
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
|
||||||
centered && "justify-center"
|
centered && "justify-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{values.map((value, index) => {
|
{values.map((value, index) => {
|
||||||
const targetObject = tabsContentArray.find(
|
const targetObject = finalTabsContentArray.find(
|
||||||
(ctn) => ctn.value == value
|
(ctn) =>
|
||||||
|
ctn.value == value ||
|
||||||
|
twuiSlugify(ctn.title) == value
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = value == activeValue;
|
const isActive = value == activeValue;
|
||||||
@ -80,9 +107,9 @@ export default function Tabs({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"px-6 py-2 rounded -ml-[1px]",
|
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
|
||||||
isActive
|
isActive
|
||||||
? "bg-blue-500 text-white outline-none twui-tab-button-active"
|
? "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" +
|
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
|
||||||
" cursor-pointer",
|
" cursor-pointer",
|
||||||
"twui-tab-buttons"
|
"twui-tab-buttons"
|
||||||
@ -102,7 +129,7 @@ export default function Tabs({
|
|||||||
</Row>
|
</Row>
|
||||||
</Border>
|
</Border>
|
||||||
</div>
|
</div>
|
||||||
{targetContent?.content}
|
{activeValue ? targetContent?.content : switchComponent || null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -16,57 +16,68 @@ export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
|||||||
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
||||||
variant?: "normal" | "outlined" | "ghost";
|
variant?: "normal" | "outlined" | "ghost";
|
||||||
href?: string;
|
href?: string;
|
||||||
|
newTab?: boolean;
|
||||||
|
linkProps?: React.DetailedHTMLProps<
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
HTMLAnchorElement
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Tabs Component
|
* # Tabs Component
|
||||||
* @className twui-tag
|
* @className twui-tag
|
||||||
|
* @className twui-tag-primary-outlined
|
||||||
*/
|
*/
|
||||||
export default function Tag({
|
export default function Tag({
|
||||||
color,
|
color,
|
||||||
variant,
|
variant,
|
||||||
children,
|
children,
|
||||||
href,
|
href,
|
||||||
|
newTab,
|
||||||
|
linkProps,
|
||||||
...props
|
...props
|
||||||
}: TWUI_TOGGLE_PROPS) {
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
const mainComponent = (
|
const mainComponent = (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-xs px-2 py-0.5 rounded-full outline outline-0",
|
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||||
"text-center flex items-center justify-center",
|
"text-center flex items-center justify-center",
|
||||||
color == "secondary"
|
color == "secondary"
|
||||||
? "bg-violet-600 outline-violet-600"
|
? "bg-secondary text-white outline-secbg-secondary"
|
||||||
: color == "success"
|
: color == "success"
|
||||||
? "bg-emerald-700 outline-emerald-700"
|
? "bg-success outline-success text-white"
|
||||||
: color == "error"
|
: color == "error"
|
||||||
? "bg-orange-700 outline-orange-700"
|
? "bg-orange-700 outline-orange-700"
|
||||||
: color == "gray"
|
: color == "gray"
|
||||||
? "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20 text-slate-500 dark:text-white"
|
? twMerge(
|
||||||
: "bg-blue-600 outline-blue-600",
|
"bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20",
|
||||||
|
"text-slate-800 dark:text-white"
|
||||||
|
)
|
||||||
|
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
|
||||||
variant == "outlined"
|
variant == "outlined"
|
||||||
? "!bg-transparent outline-1 " +
|
? "!bg-transparent outline-1 " +
|
||||||
(color == "secondary"
|
(color == "secondary"
|
||||||
? "text-violet-600"
|
? "text-secondary"
|
||||||
: color == "success"
|
: color == "success"
|
||||||
? "text-emerald-700 dark:text-emerald-400"
|
? "text-success dark:text-success-dark"
|
||||||
: color == "error"
|
: color == "error"
|
||||||
? "text-orange-700"
|
? "text-orange-700"
|
||||||
: color == "gray"
|
: color == "gray"
|
||||||
? "text-slate-700 dark:text-white/80"
|
? "text-slate-700 dark:text-white/80"
|
||||||
: "text-blue-600")
|
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
|
||||||
: variant == "ghost"
|
: variant == "ghost"
|
||||||
? "!bg-transparent outline-none border-none " +
|
? "!bg-transparent outline-none border-none " +
|
||||||
(color == "secondary"
|
(color == "secondary"
|
||||||
? "text-violet-600"
|
? "text-secondary"
|
||||||
: color == "success"
|
: color == "success"
|
||||||
? "text-emerald-700 dark:text-emerald-400"
|
? "text-success dark:text-success-dark"
|
||||||
: color == "error"
|
: color == "error"
|
||||||
? "text-orange-700"
|
? "text-orange-700"
|
||||||
: color == "gray"
|
: color == "gray"
|
||||||
? "text-slate-700 dark:text-white/80"
|
? "text-slate-700 dark:text-white/80"
|
||||||
: "text-blue-600")
|
: "text-primary dark:text-primary-dark")
|
||||||
: "text-white",
|
: "",
|
||||||
|
|
||||||
"twui-tag",
|
"twui-tag",
|
||||||
props.className
|
props.className
|
||||||
@ -78,7 +89,12 @@ export default function Tag({
|
|||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<a href={href} className={twMerge("hover:opacity-80")}>
|
<a
|
||||||
|
href={href}
|
||||||
|
target={newTab ? "_blank" : undefined}
|
||||||
|
{...linkProps}
|
||||||
|
className={twMerge("hover:opacity-80", linkProps?.className)}
|
||||||
|
>
|
||||||
{mainComponent}
|
{mainComponent}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
import Span from "../layout/Span";
|
import Span from "../layout/Span";
|
||||||
|
|
||||||
export const ToastStyles = ["normal", "success", "error"] as const;
|
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||||
@ -35,17 +35,47 @@ export default function Toast({
|
|||||||
color,
|
color,
|
||||||
...props
|
...props
|
||||||
}: TWUIToastProps) {
|
}: 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);
|
||||||
|
}, closeDelay);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
setOpen?.(false);
|
||||||
|
};
|
||||||
|
}, [ready, open]);
|
||||||
|
|
||||||
|
if (!ready) return null;
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const toastEl = (
|
return ReactDOM.createPortal(
|
||||||
<Card
|
<Card
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"pl-6 pr-8 py-4 bg-blue-700 dark:bg-blue-800",
|
"absolute bottom-4 right-4 z-[250] border-none",
|
||||||
|
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
|
||||||
color == "success"
|
color == "success"
|
||||||
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success"
|
? "bg-success dark:bg-success-dark twui-toast-success"
|
||||||
: color == "error"
|
: color == "error"
|
||||||
? "bg-orange-600 dark:bg-orange-700 twui-toast-error"
|
? "bg-error dark:bg-error-dark twui-toast-error"
|
||||||
: "",
|
: "",
|
||||||
props.className,
|
props.className,
|
||||||
"twui-toast"
|
"twui-toast"
|
||||||
@ -54,13 +84,7 @@ export default function Toast({
|
|||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeout);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
const targetEl = e.target as HTMLElement;
|
|
||||||
const rootWrapperEl = targetEl.closest(
|
|
||||||
".twui-toast-root"
|
|
||||||
) as HTMLDivElement | null;
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
closeToast({ wrapperEl: rootWrapperEl });
|
|
||||||
setOpen?.(false);
|
setOpen?.(false);
|
||||||
}, closeDelay);
|
}, closeDelay);
|
||||||
}}
|
}}
|
||||||
@ -71,48 +95,13 @@ export default function Toast({
|
|||||||
"text-white"
|
"text-white"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const targetEl = e.target as HTMLElement;
|
setOpen?.(false);
|
||||||
const rootWrapperEl = targetEl.closest(".twui-toast-root");
|
|
||||||
|
|
||||||
if (rootWrapperEl) {
|
|
||||||
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
|
|
||||||
setOpen?.(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={15} />
|
<X size={15} />
|
||||||
</Span>
|
</Span>
|
||||||
<Span className={twMerge("text-white")}>{props.children}</Span>
|
<Span className={twMerge("text-white")}>{props.children}</Span>
|
||||||
</Card>
|
</Card>,
|
||||||
|
document.getElementById(IDName) as HTMLElement
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const wrapperEl = document.createElement("div");
|
|
||||||
|
|
||||||
wrapperEl.className = twMerge(
|
|
||||||
"fixed z-[200000] bottom-10 right-10",
|
|
||||||
"flex flex-col items-center justify-center",
|
|
||||||
"twui-toast-root"
|
|
||||||
);
|
|
||||||
|
|
||||||
document.body.appendChild(wrapperEl);
|
|
||||||
const root = createRoot(wrapperEl);
|
|
||||||
root.render(toastEl);
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
closeToast({ wrapperEl });
|
|
||||||
setOpen?.(false);
|
|
||||||
}, closeDelay);
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
closeToast({ wrapperEl });
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeToast({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
|
|
||||||
if (!wrapperEl) return;
|
|
||||||
wrapperEl.parentElement?.removeChild(wrapperEl);
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,38 @@
|
|||||||
import React, {
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
DetailedHTMLProps,
|
DetailedHTMLProps,
|
||||||
HTMLAttributes,
|
HTMLAttributes,
|
||||||
InputHTMLAttributes,
|
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import CheckMarkSVG from "../svgs/CheckMarkSVG";
|
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 = DetailedHTMLProps<
|
export type CheckboxProps = React.DetailedHTMLProps<
|
||||||
InputHTMLAttributes<HTMLInputElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLInputElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
name: string;
|
|
||||||
wrapperProps?: DetailedHTMLProps<
|
wrapperProps?: DetailedHTMLProps<
|
||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
label?: string | ReactNode;
|
label?: string | ReactNode;
|
||||||
labelProps?: DetailedHTMLProps<
|
labelProps?: React.DetailedHTMLProps<
|
||||||
HTMLAttributes<HTMLLabelElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLLabelElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
defaultChecked?: boolean;
|
defaultChecked?: boolean;
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
checked?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
size?: number;
|
||||||
|
changeHandler?: (value: boolean) => void;
|
||||||
|
info?: string | ReactNode;
|
||||||
|
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,62 +46,92 @@ export default function Checkbox({
|
|||||||
label,
|
label,
|
||||||
labelProps,
|
labelProps,
|
||||||
size,
|
size,
|
||||||
name,
|
|
||||||
wrapperClassName,
|
wrapperClassName,
|
||||||
defaultChecked,
|
defaultChecked,
|
||||||
setChecked,
|
setChecked: externalSetChecked,
|
||||||
|
readOnly,
|
||||||
|
checked: externalChecked,
|
||||||
|
changeHandler,
|
||||||
|
info,
|
||||||
|
wrapperWrapperProps,
|
||||||
...props
|
...props
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
const finalSize = size || 20;
|
const finalSize = size || 20;
|
||||||
|
|
||||||
const [internalChecked, setInternalChecked] = React.useState(
|
const [checked, setChecked] = React.useState(
|
||||||
defaultChecked || false
|
defaultChecked || externalChecked || false
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkMarkRef = React.useRef<HTMLInputElement>();
|
const finalTitle = props.title
|
||||||
|
? props.title
|
||||||
|
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof externalChecked == "undefined") return;
|
||||||
|
setChecked(externalChecked);
|
||||||
|
}, [externalChecked]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
changeHandler?.(checked);
|
||||||
|
}, [checked]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Stack
|
||||||
{...wrapperProps}
|
{...wrapperWrapperProps}
|
||||||
onClick={(e) => {
|
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
|
||||||
checkMarkRef.current?.click();
|
|
||||||
wrapperProps?.onClick?.(e);
|
|
||||||
}}
|
|
||||||
className={twMerge(
|
|
||||||
"flex items-center gap-2",
|
|
||||||
wrapperClassName,
|
|
||||||
wrapperProps?.className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
{...props}
|
|
||||||
width={finalSize}
|
|
||||||
height={finalSize}
|
|
||||||
className={twMerge("hidden")}
|
|
||||||
name={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInternalChecked(e.target.checked);
|
|
||||||
setChecked?.(e.target.checked);
|
|
||||||
}}
|
|
||||||
ref={checkMarkRef as any}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
|
{...wrapperProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex items-center justify-center p-[3px] rounded",
|
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
|
||||||
internalChecked
|
readOnly ? "opacity-70 pointer-events-none" : "",
|
||||||
? "bg-emerald-700 twui-checkbox-checked"
|
wrapperClassName,
|
||||||
: "outline-slate-600 dark:outline-white/50 outline-2 outline -outline-offset-2 twui-checkbox-unchecked",
|
wrapperProps?.className
|
||||||
"twui-checkbox"
|
|
||||||
)}
|
)}
|
||||||
style={{
|
onClick={() => {
|
||||||
width: finalSize + "px",
|
setChecked(!checked);
|
||||||
height: finalSize + "px",
|
externalSetChecked?.(!checked);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{internalChecked && <CheckMarkSVG />}
|
<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>
|
||||||
|
<Stack className="gap-0.5">
|
||||||
|
<div
|
||||||
|
{...labelProps}
|
||||||
|
className={twMerge(
|
||||||
|
"select-none whitespace-normal md:whitespace-nowrap",
|
||||||
|
labelProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label || finalTitle}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
{label && <label>{label}</label>}
|
{info && (
|
||||||
</div>
|
<Row className="gap-1" title={info.toString()}>
|
||||||
|
<Info size={12} className="opacity-40" />
|
||||||
|
<Span size="smaller" className="opacity-70">
|
||||||
|
{info}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,25 @@
|
|||||||
import Button from "../layout/Button";
|
import Button from "../layout/Button";
|
||||||
import Stack from "../layout/Stack";
|
import Stack from "../layout/Stack";
|
||||||
import {
|
import { FileArchive, FilePlus2, X } from "lucide-react";
|
||||||
File,
|
import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
|
||||||
FileArchive,
|
|
||||||
FilePlus,
|
|
||||||
FilePlus2,
|
|
||||||
ImagePlus,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { DetailedHTMLProps } from "react";
|
|
||||||
import Card from "../elements/Card";
|
import Card from "../elements/Card";
|
||||||
import Span from "../layout/Span";
|
import Span from "../layout/Span";
|
||||||
import Center from "../layout/Center";
|
import Center from "../layout/Center";
|
||||||
import imageInputToBase64, {
|
import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
|
||||||
FileInputToBase64FunctionReturn,
|
|
||||||
} from "../utils/form/fileInputToBase64";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import fileInputToBase64 from "../utils/form/fileInputToBase64";
|
import fileInputToBase64 from "../utils/form/fileInputToBase64";
|
||||||
import Row from "../layout/Row";
|
import Row from "../layout/Row";
|
||||||
|
import Input from "./Input";
|
||||||
|
import Loading from "../elements/Loading";
|
||||||
|
|
||||||
type ImageUploadProps = DetailedHTMLProps<
|
type ImageUploadProps = DetailedHTMLProps<
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
onChangeHandler?: (
|
onChangeHandler?: (
|
||||||
imgData: FileInputToBase64FunctionReturn | undefined
|
fileData: FileInputToBase64FunctionReturn | undefined
|
||||||
) => any;
|
) => any;
|
||||||
|
onClear?: () => void;
|
||||||
fileInputProps?: DetailedHTMLProps<
|
fileInputProps?: DetailedHTMLProps<
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
HTMLInputElement
|
HTMLInputElement
|
||||||
@ -42,12 +36,21 @@ type ImageUploadProps = DetailedHTMLProps<
|
|||||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||||
HTMLImageElement
|
HTMLImageElement
|
||||||
>;
|
>;
|
||||||
label?: string;
|
label?: string | ReactNode;
|
||||||
disablePreview?: boolean;
|
disablePreview?: boolean;
|
||||||
allowedRegex?: RegExp;
|
allowedRegex?: RegExp;
|
||||||
externalSetFile?: React.Dispatch<
|
externalSetFile?: React.Dispatch<
|
||||||
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
|
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
|
||||||
>;
|
>;
|
||||||
|
externalSetFiles?: React.Dispatch<
|
||||||
|
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
|
||||||
|
>;
|
||||||
|
existingFile?: FileInputToBase64FunctionReturn;
|
||||||
|
existingFileUrl?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
labelSpanProps?: ComponentProps<typeof Span>;
|
||||||
|
loading?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,12 +66,37 @@ export default function FileUpload({
|
|||||||
disablePreview,
|
disablePreview,
|
||||||
allowedRegex,
|
allowedRegex,
|
||||||
externalSetFile,
|
externalSetFile,
|
||||||
|
externalSetFiles,
|
||||||
|
existingFile,
|
||||||
|
existingFileUrl,
|
||||||
|
icon,
|
||||||
|
labelSpanProps,
|
||||||
|
loading,
|
||||||
|
multiple,
|
||||||
|
onClear,
|
||||||
...props
|
...props
|
||||||
}: ImageUploadProps) {
|
}: ImageUploadProps) {
|
||||||
const [file, setFile] = React.useState<
|
const [file, setFile] = React.useState<
|
||||||
FileInputToBase64FunctionReturn | undefined
|
FileInputToBase64FunctionReturn | undefined
|
||||||
>(undefined);
|
>(existingFile);
|
||||||
const inputRef = React.useRef<HTMLInputElement>();
|
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
|
||||||
|
existingFileUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (existingFileUrl) {
|
||||||
|
setFileUrl(existingFileUrl);
|
||||||
|
}
|
||||||
|
}, [existingFileUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (existingFile) {
|
||||||
|
setFile(existingFile);
|
||||||
|
}
|
||||||
|
}, [existingFile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@ -77,26 +105,117 @@ export default function FileUpload({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple={multiple}
|
||||||
className={twMerge("hidden", fileInputProps?.className)}
|
className={twMerge("hidden", fileInputProps?.className)}
|
||||||
{...fileInputProps}
|
{...fileInputProps}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const inputFile = e.target.files?.[0];
|
if (multiple) {
|
||||||
|
(async () => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files?.[0]) return;
|
||||||
|
|
||||||
if (!inputFile) return;
|
let filesArr: FileInputToBase64FunctionReturn[] =
|
||||||
|
[];
|
||||||
|
|
||||||
fileInputToBase64({ inputFile, allowedRegex }).then(
|
for (let i = 0; i < files.length; i++) {
|
||||||
(res) => {
|
const file = files[i];
|
||||||
setFile(res);
|
const fileObj = await fileInputToBase64({
|
||||||
externalSetFile?.(res);
|
inputFile: file,
|
||||||
onChangeHandler?.(res);
|
});
|
||||||
fileInputProps?.onChange?.(e);
|
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);
|
||||||
|
fileInputProps?.onChange?.(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
ref={inputRef as any}
|
ref={inputRef as any}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{file ? (
|
{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) => {
|
||||||
|
setFile(undefined);
|
||||||
|
externalSetFile?.(undefined);
|
||||||
|
onChangeHandler?.(undefined);
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
onClear?.();
|
||||||
|
}}
|
||||||
|
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
|
<Card
|
||||||
className="w-full relative h-full items-center justify-center overflow-hidden"
|
className="w-full relative h-full items-center justify-center overflow-hidden"
|
||||||
{...previewImageWrapperProps}
|
{...previewImageWrapperProps}
|
||||||
@ -105,22 +224,21 @@ export default function FileUpload({
|
|||||||
<Span className="opacity-50" size="small">
|
<Span className="opacity-50" size="small">
|
||||||
Image Uploaded!
|
Image Uploaded!
|
||||||
</Span>
|
</Span>
|
||||||
) : file.fileType?.match(/image/i) ? (
|
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
||||||
<img
|
|
||||||
src={file.fileBase64Full}
|
|
||||||
className="w-full object-contain overflow-hidden"
|
|
||||||
{...previewImageProps}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Row>
|
<Row>
|
||||||
<FileArchive size={36} strokeWidth={1} />
|
<FileArchive size={36} strokeWidth={1} />
|
||||||
<Stack className="gap-0">
|
<Stack className="gap-0">
|
||||||
<Span>{file.file?.name || file.fileName}</Span>
|
|
||||||
<Span size="smaller" className="opacity-70">
|
<Span size="smaller" className="opacity-70">
|
||||||
{file.fileType}
|
{fileUrl}
|
||||||
</Span>
|
</Span>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
className="w-full object-contain overflow-hidden"
|
||||||
|
{...previewImageProps}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -132,7 +250,9 @@ export default function FileUpload({
|
|||||||
setFile(undefined);
|
setFile(undefined);
|
||||||
externalSetFile?.(undefined);
|
externalSetFile?.(undefined);
|
||||||
onChangeHandler?.(undefined);
|
onChangeHandler?.(undefined);
|
||||||
|
setFileUrl(undefined);
|
||||||
}}
|
}}
|
||||||
|
title="Cancel File Button"
|
||||||
>
|
>
|
||||||
<X className="text-slate-950 dark:text-white" />
|
<X className="text-slate-950 dark:text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -140,19 +260,63 @@ export default function FileUpload({
|
|||||||
) : (
|
) : (
|
||||||
<Card
|
<Card
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
|
"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
|
placeHolderWrapper?.className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
placeHolderWrapper?.onClick?.(e);
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
{...placeHolderWrapper}
|
{...placeHolderWrapper}
|
||||||
>
|
>
|
||||||
<Center>
|
<Center
|
||||||
|
className={twMerge(
|
||||||
|
fileDraggedOver ? "pointer-events-none" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Stack className="items-center gap-2">
|
<Stack className="items-center gap-2">
|
||||||
<FilePlus2 className="text-slate-400" />
|
{icon || <FilePlus2 className="text-slate-400" />}
|
||||||
<Span size="smaller" variant="faded">
|
<Span
|
||||||
|
size="smaller"
|
||||||
|
variant="faded"
|
||||||
|
{...labelSpanProps}
|
||||||
|
>
|
||||||
{label || "Click to Upload File"}
|
{label || "Click to Upload File"}
|
||||||
</Span>
|
</Span>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -2,16 +2,20 @@ import _ from "lodash";
|
|||||||
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
|
import { DetailedHTMLProps, FormHTMLAttributes } 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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Form Element
|
* # Form Element
|
||||||
* @className twui-form
|
* @className twui-form
|
||||||
*/
|
*/
|
||||||
export default function Form<T extends object = { [key: string]: any }>({
|
export default function Form<
|
||||||
...props
|
T extends { [key: string]: any } = { [key: string]: any }
|
||||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
>({ ...props }: Props<T>) {
|
||||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
|
||||||
}) {
|
|
||||||
const finalProps = _.omit(props, "submitHandler");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@ -29,6 +33,15 @@ export default function Form<T extends object = { [key: string]: any }>({
|
|||||||
props.submitHandler?.(e, data);
|
props.submitHandler?.(e, data);
|
||||||
props.onSubmit?.(e);
|
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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</form>
|
</form>
|
||||||
|
@ -9,6 +9,7 @@ import imageInputToBase64, {
|
|||||||
ImageInputToBase64FunctionReturn,
|
ImageInputToBase64FunctionReturn,
|
||||||
} from "../utils/form/imageInputToBase64";
|
} from "../utils/form/imageInputToBase64";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import Tag from "../elements/Tag";
|
||||||
|
|
||||||
type ImageUploadProps = DetailedHTMLProps<
|
type ImageUploadProps = DetailedHTMLProps<
|
||||||
React.HTMLAttributes<HTMLDivElement>,
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
@ -35,6 +36,17 @@ type ImageUploadProps = DetailedHTMLProps<
|
|||||||
>;
|
>;
|
||||||
label?: string;
|
label?: string;
|
||||||
disablePreview?: boolean;
|
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>>;
|
||||||
|
externalImage?: ImageInputToBase64FunctionReturn;
|
||||||
|
restoreImageFn?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,53 +60,118 @@ export default function ImageUpload({
|
|||||||
previewImageProps,
|
previewImageProps,
|
||||||
label,
|
label,
|
||||||
disablePreview,
|
disablePreview,
|
||||||
|
existingImageUrl,
|
||||||
|
externalSetImage,
|
||||||
|
externalSetImages,
|
||||||
|
externalImage,
|
||||||
|
multiple,
|
||||||
|
restoreImageFn,
|
||||||
|
setLoading,
|
||||||
...props
|
...props
|
||||||
}: ImageUploadProps) {
|
}: ImageUploadProps) {
|
||||||
const [src, setSrc] = React.useState<string | undefined>(undefined);
|
const [imageObject, setImageObject] = React.useState<
|
||||||
const inputRef = React.useRef<HTMLInputElement>();
|
ImageInputToBase64FunctionReturn | undefined
|
||||||
|
>(externalImage);
|
||||||
|
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (existingImageUrl) setSrc(existingImageUrl);
|
||||||
|
}, [existingImageUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge("w-full h-[300px]", props?.className)}
|
className={twMerge(
|
||||||
|
"w-full h-[300px] overflow-hidden",
|
||||||
|
props?.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className={twMerge("hidden", fileInputProps?.className)}
|
className={twMerge("hidden", fileInputProps?.className)}
|
||||||
|
multiple={multiple}
|
||||||
|
accept="image/*"
|
||||||
{...fileInputProps}
|
{...fileInputProps}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
imageInputToBase64({ imageInput: e.target }).then((res) => {
|
setLoading?.(true);
|
||||||
setSrc(res.imageBase64Full);
|
|
||||||
onChangeHandler?.(res);
|
if (multiple) {
|
||||||
fileInputProps?.onChange?.(e);
|
(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}
|
ref={inputRef as any}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{src ? (
|
{src || imageObject?.imageBase64Full ? (
|
||||||
<Card
|
<Card
|
||||||
className="w-full relative h-full items-center justify-center"
|
className="w-full relative h-full items-center justify-center"
|
||||||
{...previewImageWrapperProps}
|
{...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 ? (
|
{disablePreview ? (
|
||||||
<Span className="opacity-50" size="small">
|
<Span className="opacity-50" size="small">
|
||||||
Image Uploaded!
|
Image Uploaded!
|
||||||
</Span>
|
</Span>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={imageObject?.imageBase64Full || src}
|
||||||
className="w-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
{...previewImageProps}
|
{...previewImageProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute p-2 top-2 right-2 z-20"
|
className={twMerge(
|
||||||
|
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark"
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
onChangeHandler?.(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" />
|
<X className="text-slate-950 dark:text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -106,6 +183,11 @@ export default function ImageUpload({
|
|||||||
placeHolderWrapper?.className
|
placeHolderWrapper?.className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
const targetEl = e.target as HTMLElement | undefined;
|
||||||
|
if (targetEl?.closest(".cancel-upload")) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
placeHolderWrapper?.onClick?.(e);
|
placeHolderWrapper?.onClick?.(e);
|
||||||
}}
|
}}
|
||||||
@ -117,6 +199,20 @@ export default function ImageUpload({
|
|||||||
<Span size="smaller" variant="faded">
|
<Span size="smaller" variant="faded">
|
||||||
{label || "Click to Upload Image"}
|
{label || "Click to Upload Image"}
|
||||||
</Span>
|
</Span>
|
||||||
|
{existingImageUrl && (
|
||||||
|
<Button
|
||||||
|
title="Restore Image Button"
|
||||||
|
size="smaller"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
restoreImageFn?.() ||
|
||||||
|
setSrc(existingImageUrl);
|
||||||
|
}}
|
||||||
|
className="cancel-upload"
|
||||||
|
>
|
||||||
|
Restore Original Image
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -1,287 +0,0 @@
|
|||||||
import React, {
|
|
||||||
DetailedHTMLProps,
|
|
||||||
InputHTMLAttributes,
|
|
||||||
LabelHTMLAttributes,
|
|
||||||
RefObject,
|
|
||||||
TextareaHTMLAttributes,
|
|
||||||
} from "react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import Span from "../layout/Span";
|
|
||||||
|
|
||||||
let timeout: any;
|
|
||||||
|
|
||||||
const autocompleteOptions = [
|
|
||||||
// Personal Information
|
|
||||||
"name",
|
|
||||||
"honorific-prefix",
|
|
||||||
"given-name",
|
|
||||||
"additional-name",
|
|
||||||
"family-name",
|
|
||||||
"honorific-suffix",
|
|
||||||
"nickname",
|
|
||||||
|
|
||||||
// Contact Information
|
|
||||||
"email",
|
|
||||||
"username",
|
|
||||||
"new-password",
|
|
||||||
"current-password",
|
|
||||||
"one-time-code",
|
|
||||||
"organization-title",
|
|
||||||
"organization",
|
|
||||||
|
|
||||||
// Address Fields
|
|
||||||
"street-address",
|
|
||||||
"address-line1",
|
|
||||||
"address-line2",
|
|
||||||
"address-line3",
|
|
||||||
"address-level4",
|
|
||||||
"address-level3",
|
|
||||||
"address-level2",
|
|
||||||
"address-level1",
|
|
||||||
"country",
|
|
||||||
"country-name",
|
|
||||||
"postal-code",
|
|
||||||
|
|
||||||
// Phone Numbers
|
|
||||||
"tel",
|
|
||||||
"tel-country-code",
|
|
||||||
"tel-national",
|
|
||||||
"tel-area-code",
|
|
||||||
"tel-local",
|
|
||||||
"tel-extension",
|
|
||||||
|
|
||||||
// Dates
|
|
||||||
"bday",
|
|
||||||
"bday-day",
|
|
||||||
"bday-month",
|
|
||||||
"bday-year",
|
|
||||||
|
|
||||||
// Payment Information
|
|
||||||
"cc-name",
|
|
||||||
"cc-given-name",
|
|
||||||
"cc-additional-name",
|
|
||||||
"cc-family-name",
|
|
||||||
"cc-number",
|
|
||||||
"cc-exp",
|
|
||||||
"cc-exp-month",
|
|
||||||
"cc-exp-year",
|
|
||||||
"cc-csc",
|
|
||||||
"cc-type",
|
|
||||||
|
|
||||||
// Additional Options
|
|
||||||
"sex",
|
|
||||||
"url",
|
|
||||||
"photo",
|
|
||||||
|
|
||||||
// Special Values
|
|
||||||
"on", // Enables autofill (default)
|
|
||||||
"off", // Disables autofill
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type InputProps<KeyType extends string> = DetailedHTMLProps<
|
|
||||||
InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
HTMLInputElement
|
|
||||||
> &
|
|
||||||
DetailedHTMLProps<
|
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
||||||
HTMLTextAreaElement
|
|
||||||
> & {
|
|
||||||
label?: string;
|
|
||||||
variant?: "normal" | "warning" | "error" | "inactive";
|
|
||||||
prefix?: string | React.ReactNode;
|
|
||||||
suffix?: string | React.ReactNode;
|
|
||||||
showLabel?: boolean;
|
|
||||||
istextarea?: boolean;
|
|
||||||
wrapperProps?: DetailedHTMLProps<
|
|
||||||
InputHTMLAttributes<HTMLDivElement>,
|
|
||||||
HTMLDivElement
|
|
||||||
>;
|
|
||||||
labelProps?: DetailedHTMLProps<
|
|
||||||
LabelHTMLAttributes<HTMLLabelElement>,
|
|
||||||
HTMLLabelElement
|
|
||||||
>;
|
|
||||||
componentRef?: RefObject<any>;
|
|
||||||
validationRegex?: RegExp;
|
|
||||||
debounce?: number;
|
|
||||||
invalidMessage?: string;
|
|
||||||
validationFunction?: (value: string) => Promise<boolean>;
|
|
||||||
autoComplete?: (typeof autocompleteOptions)[number];
|
|
||||||
name?: KeyType;
|
|
||||||
valueUpdate?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* # Input Element
|
|
||||||
* @className twui-input
|
|
||||||
* @className twui-input-wrapper
|
|
||||||
* @className twui-input-invalid
|
|
||||||
*/
|
|
||||||
export default function Input<KeyType extends string>({
|
|
||||||
label,
|
|
||||||
variant,
|
|
||||||
prefix,
|
|
||||||
suffix,
|
|
||||||
componentRef,
|
|
||||||
labelProps,
|
|
||||||
wrapperProps,
|
|
||||||
showLabel,
|
|
||||||
istextarea,
|
|
||||||
debounce,
|
|
||||||
invalidMessage,
|
|
||||||
autoComplete,
|
|
||||||
validationFunction,
|
|
||||||
validationRegex,
|
|
||||||
valueUpdate,
|
|
||||||
...props
|
|
||||||
}: InputProps<KeyType>) {
|
|
||||||
const [focus, setFocus] = React.useState(false);
|
|
||||||
const [value, setValue] = React.useState(props.defaultValue || props.value);
|
|
||||||
|
|
||||||
const [isValid, setIsValid] = React.useState(true);
|
|
||||||
|
|
||||||
const DEFAULT_DEBOUNCE = 500;
|
|
||||||
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof value == "string") {
|
|
||||||
if (!value.match(/./)) return setIsValid(true);
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (validationRegex) {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
setIsValid(validationRegex.test(value));
|
|
||||||
}, finalDebounce);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationFunction) {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
validationFunction(value).then((res) => {
|
|
||||||
setIsValid(res);
|
|
||||||
});
|
|
||||||
}, finalDebounce);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setValue(props.value || "");
|
|
||||||
}, [props.value]);
|
|
||||||
|
|
||||||
const targetComponent = istextarea ? (
|
|
||||||
<textarea
|
|
||||||
{...props}
|
|
||||||
className={twMerge(
|
|
||||||
"w-full outline-none bg-transparent",
|
|
||||||
"twui-textarea",
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
ref={componentRef}
|
|
||||||
onFocus={(e) => {
|
|
||||||
setFocus(true);
|
|
||||||
props?.onFocus?.(e);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
setFocus(false);
|
|
||||||
props?.onBlur?.(e);
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
props?.onChange?.(e);
|
|
||||||
}}
|
|
||||||
autoComplete={autoComplete}
|
|
||||||
rows={props.height ? Number(props.height) : 4}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
{...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",
|
|
||||||
"twui-input",
|
|
||||||
props.className
|
|
||||||
)}
|
|
||||||
ref={componentRef}
|
|
||||||
onFocus={(e) => {
|
|
||||||
setFocus(true);
|
|
||||||
props?.onFocus?.(e);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
setFocus(false);
|
|
||||||
props?.onBlur?.(e);
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setValue(e.target.value);
|
|
||||||
props?.onChange?.(e);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...wrapperProps}
|
|
||||||
className={twMerge(
|
|
||||||
"relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1",
|
|
||||||
focus && isValid
|
|
||||||
? "border-slate-700 dark:border-white/50"
|
|
||||||
: "border-slate-300 dark:border-white/20",
|
|
||||||
focus && isValid
|
|
||||||
? "outline-slate-700 dark:outline-white/50"
|
|
||||||
: "outline-slate-300 dark:outline-white/20",
|
|
||||||
variant == "warning" &&
|
|
||||||
isValid &&
|
|
||||||
"border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300",
|
|
||||||
variant == "error" &&
|
|
||||||
isValid &&
|
|
||||||
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
|
|
||||||
variant == "inactive" &&
|
|
||||||
isValid &&
|
|
||||||
"opacity-40 pointer-events-none",
|
|
||||||
"bg-white dark:bg-black",
|
|
||||||
isValid
|
|
||||||
? ""
|
|
||||||
: "border-orange-500 outline-orange-500 twui-input-invalid",
|
|
||||||
props.readOnly && "opacity-50 pointer-events-none",
|
|
||||||
"twui-input-wrapper",
|
|
||||||
wrapperProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showLabel && (
|
|
||||||
<label
|
|
||||||
htmlFor={props.name}
|
|
||||||
{...labelProps}
|
|
||||||
className={twMerge(
|
|
||||||
"text-xs absolute -top-2.5 left-2 text-slate-500 bg-white px-1.5 rounded-t",
|
|
||||||
"dark:text-white/60 dark:bg-black",
|
|
||||||
"twui-input-label",
|
|
||||||
labelProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label || props.placeholder || props.name}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{prefix && (
|
|
||||||
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
|
||||||
{prefix}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{targetComponent}
|
|
||||||
|
|
||||||
{suffix && (
|
|
||||||
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
|
||||||
{suffix}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isValid && (
|
|
||||||
<Span className="opacity-30 pointer-events-none whitespace-nowrap">
|
|
||||||
{invalidMessage || "Invalid"}
|
|
||||||
</Span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
121
components/lib/form/Input/NumberInputButtons.tsx
Normal file
121
components/lib/form/Input/NumberInputButtons.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
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"> & {
|
||||||
|
updateValue: (v: string) => void;
|
||||||
|
getNormalizedValue: (v: string) => void;
|
||||||
|
buttonDownRef: React.MutableRefObject<boolean>;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Input Number Text Buttons
|
||||||
|
*/
|
||||||
|
export default function NumberInputButtons({
|
||||||
|
getNormalizedValue,
|
||||||
|
updateValue,
|
||||||
|
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 updateValue(String(max));
|
||||||
|
} else if (min && existingNumberValue < twuiNumberfy(min)) {
|
||||||
|
return updateValue(String(min));
|
||||||
|
} else {
|
||||||
|
updateValue(
|
||||||
|
String(existingNumberValue + twuiNumberfy(step || DEFAULT_STEP))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
const existingValue = inputRef.current?.value;
|
||||||
|
const existingNumberValue = twuiNumberfy(existingValue);
|
||||||
|
|
||||||
|
if (min && existingNumberValue <= twuiNumberfy(min)) {
|
||||||
|
updateValue(String(min));
|
||||||
|
} else {
|
||||||
|
updateValue(
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
517
components/lib/form/Input/index.tsx
Normal file
517
components/lib/form/Input/index.tsx
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
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> = DetailedHTMLProps<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
HTMLInputElement
|
||||||
|
> &
|
||||||
|
DetailedHTMLProps<
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
HTMLTextAreaElement
|
||||||
|
> & {
|
||||||
|
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,
|
||||||
|
element?: HTMLInputElement | HTMLTextAreaElement
|
||||||
|
) => void;
|
||||||
|
autoComplete?: (typeof AutocompleteOptions)[number];
|
||||||
|
name?: KeyType;
|
||||||
|
valueUpdate?: string;
|
||||||
|
numberText?: boolean;
|
||||||
|
setReady?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
decimal?: number;
|
||||||
|
info?: string | ReactNode;
|
||||||
|
ready?: boolean;
|
||||||
|
validity?: TWUISelectValidityObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
...props
|
||||||
|
} = inputProps;
|
||||||
|
|
||||||
|
function getFinalValue(v: any) {
|
||||||
|
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 [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,
|
||||||
|
el?: HTMLInputElement | HTMLTextAreaElement
|
||||||
|
) => {
|
||||||
|
if (buttonDownRef.current) return;
|
||||||
|
|
||||||
|
if (changeHandler) {
|
||||||
|
window.clearTimeout(externalValueChangeTimeout);
|
||||||
|
externalValueChangeTimeout = setTimeout(() => {
|
||||||
|
changeHandler(val, el);
|
||||||
|
}, finalDebounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof val == "string") {
|
||||||
|
if (!val.match(/./)) {
|
||||||
|
setValidity({ isValid: true });
|
||||||
|
props.value = "";
|
||||||
|
if (istextarea && textAreaRef.current) {
|
||||||
|
textAreaRef.current.value = "";
|
||||||
|
} else if (inputRef?.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (validationRegex && !validationFunction) {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
setValidity({
|
||||||
|
isValid: validationRegex.test(val),
|
||||||
|
msg: "Value mismatch",
|
||||||
|
});
|
||||||
|
}, finalDebounce);
|
||||||
|
} else if (validationFunction) {
|
||||||
|
window.clearTimeout(validationFnTimeout);
|
||||||
|
|
||||||
|
validationFnTimeout = setTimeout(() => {
|
||||||
|
if (validationRegex && !validationRegex.test(val)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validationFunction(val, el).then((res) => {
|
||||||
|
setValidity(res);
|
||||||
|
});
|
||||||
|
}, finalDebounce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof props.value !== "string" || !props.value.match(/./)) return;
|
||||||
|
updateValueFn(String(props.value));
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
function handleValueChange(
|
||||||
|
e: React.ChangeEvent<HTMLInputElement> &
|
||||||
|
React.ChangeEvent<HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
updateValue(newValue, e.target);
|
||||||
|
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, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetComponent = istextarea ? (
|
||||||
|
<textarea
|
||||||
|
placeholder={
|
||||||
|
props.name ? twuiSlugToNormalText(props.name) : undefined
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full outline-none bg-transparent",
|
||||||
|
"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",
|
||||||
|
"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}
|
||||||
|
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-10",
|
||||||
|
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 && (
|
||||||
|
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
||||||
|
{prefix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetComponent}
|
||||||
|
|
||||||
|
{props.type == "search" || props.readOnly ? null : (
|
||||||
|
<div
|
||||||
|
title="Clear Input Field"
|
||||||
|
className={twMerge(
|
||||||
|
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer",
|
||||||
|
"bg-background-light dark:bg-background-dark",
|
||||||
|
"twui-clear-input-field-button"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
if (textAreaRef.current) {
|
||||||
|
textAreaRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValue("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</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 ? (
|
||||||
|
<div
|
||||||
|
{...suffixProps}
|
||||||
|
className={twMerge(
|
||||||
|
"opacity-60 pointer-events-none whitespace-nowrap",
|
||||||
|
suffixProps?.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suffix}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{numberText ? (
|
||||||
|
<NumberInputButtons
|
||||||
|
updateValue={updateValue}
|
||||||
|
inputRef={inputRef}
|
||||||
|
getNormalizedValue={getNormalizedValue}
|
||||||
|
max={props.max}
|
||||||
|
min={props.min}
|
||||||
|
step={props.step}
|
||||||
|
buttonDownRef={buttonDownRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* {info && (
|
||||||
|
<Dropdown
|
||||||
|
target={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
title="Input Info Button"
|
||||||
|
>
|
||||||
|
<Info
|
||||||
|
size={15}
|
||||||
|
className="opacity-50 hover:opacity-100"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
hoverOpen
|
||||||
|
>
|
||||||
|
<Card className="min-w-[250px] text-sm p-6">
|
||||||
|
{typeof info == "string" ? (
|
||||||
|
<Span className="text-sm">{info}</Span>
|
||||||
|
) : (
|
||||||
|
info
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Dropdown>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
{info && (
|
||||||
|
<Dropdown
|
||||||
|
target={
|
||||||
|
<Row className="gap-1">
|
||||||
|
<Info size={12} className="opacity-40" />
|
||||||
|
<Span size="smaller" className="opacity-70">
|
||||||
|
{info}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
openDebounce={700}
|
||||||
|
hoverOpen
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
className={twMerge(
|
||||||
|
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
|
||||||
|
"max-w-[300px] w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
293
components/lib/form/SearchSelect.tsx
Normal file
293
components/lib/form/SearchSelect.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
const [value, setValue] = React.useState<
|
||||||
|
TWUISelectOptionObject<KeyType, T>
|
||||||
|
>({
|
||||||
|
value: defaultOption.value,
|
||||||
|
data: defaultOption.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
dispatchState?.(value.data);
|
||||||
|
setInputValue(value.value);
|
||||||
|
clearTimeout(focusTimeout);
|
||||||
|
setOpen(false);
|
||||||
|
changeHandler?.(value.value);
|
||||||
|
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="Search"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
targetWrapperProps={{ className: "w-full" }}
|
||||||
|
contentWrapperProps={{ className: "w-full" }}
|
||||||
|
className="w-full"
|
||||||
|
externalOpen={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.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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Dropdown>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
@ -1,39 +1,73 @@
|
|||||||
import { ChevronDown, LucideProps } from "lucide-react";
|
import { ChevronDown, Info, LucideProps } from "lucide-react";
|
||||||
import {
|
import React, {
|
||||||
|
ComponentProps,
|
||||||
DetailedHTMLProps,
|
DetailedHTMLProps,
|
||||||
ForwardRefExoticComponent,
|
Dispatch,
|
||||||
InputHTMLAttributes,
|
InputHTMLAttributes,
|
||||||
LabelHTMLAttributes,
|
LabelHTMLAttributes,
|
||||||
RefAttributes,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
SelectHTMLAttributes,
|
SelectHTMLAttributes,
|
||||||
|
SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
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";
|
||||||
|
|
||||||
type SelectOptionObject = {
|
export type TWUISelectValidityObject = {
|
||||||
title: string;
|
isValid?: boolean;
|
||||||
value: string;
|
msg?: string;
|
||||||
default?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectProps = DetailedHTMLProps<
|
export type TWUISelectOptionObject<
|
||||||
|
KeyType extends string,
|
||||||
|
T extends { [k: string]: any } = any
|
||||||
|
> = {
|
||||||
|
title?: string;
|
||||||
|
value: KeyType;
|
||||||
|
default?: boolean;
|
||||||
|
data?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TWUISelectProps<
|
||||||
|
KeyType extends string,
|
||||||
|
T extends { [k: string]: any } = any
|
||||||
|
> = DetailedHTMLProps<
|
||||||
SelectHTMLAttributes<HTMLSelectElement>,
|
SelectHTMLAttributes<HTMLSelectElement>,
|
||||||
HTMLSelectElement
|
HTMLSelectElement
|
||||||
> & {
|
> & {
|
||||||
options: SelectOptionObject[];
|
options: TWUISelectOptionObject<KeyType, T>[];
|
||||||
label?: string;
|
label?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
wrapperProps?: DetailedHTMLProps<
|
wrapperProps?: DetailedHTMLProps<
|
||||||
InputHTMLAttributes<HTMLDivElement>,
|
InputHTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
|
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||||
labelProps?: DetailedHTMLProps<
|
labelProps?: DetailedHTMLProps<
|
||||||
LabelHTMLAttributes<HTMLLabelElement>,
|
LabelHTMLAttributes<HTMLLabelElement>,
|
||||||
HTMLLabelElement
|
HTMLLabelElement
|
||||||
>;
|
>;
|
||||||
componentRef?: RefObject<HTMLSelectElement>;
|
componentRef?: RefObject<HTMLSelectElement>;
|
||||||
iconProps?: LucideProps;
|
iconProps?: LucideProps;
|
||||||
changeHandler?: (value: SelectProps["options"][number]["value"]) => void;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +76,10 @@ type SelectProps = DetailedHTMLProps<
|
|||||||
* @className twui-select
|
* @className twui-select
|
||||||
* @className twui-select-dropdown-icon
|
* @className twui-select-dropdown-icon
|
||||||
*/
|
*/
|
||||||
export default function Select({
|
export default function Select<
|
||||||
|
KeyType extends string,
|
||||||
|
T extends { [k: string]: any } = { [k: string]: any }
|
||||||
|
>({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
componentRef,
|
componentRef,
|
||||||
@ -51,76 +88,164 @@ export default function Select({
|
|||||||
showLabel,
|
showLabel,
|
||||||
iconProps,
|
iconProps,
|
||||||
changeHandler,
|
changeHandler,
|
||||||
|
info,
|
||||||
|
validateValueFn,
|
||||||
|
wrapperWrapperProps,
|
||||||
|
dispatchState,
|
||||||
...props
|
...props
|
||||||
}: SelectProps) {
|
}: TWUISelectProps<KeyType, T>) {
|
||||||
return (
|
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
|
||||||
<div
|
isValid: true,
|
||||||
{...wrapperProps}
|
});
|
||||||
className={twMerge(
|
|
||||||
"relative w-full flex items-center",
|
|
||||||
wrapperProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showLabel && (
|
|
||||||
<label
|
|
||||||
htmlFor={props.name}
|
|
||||||
{...labelProps}
|
|
||||||
className={twMerge(
|
|
||||||
"text-xs absolute -top-2.5 left-2 text-slate-500 bg-white px-1.5 rounded-t",
|
|
||||||
"dark:text-white/60 dark:bg-black",
|
|
||||||
"twui-input-label",
|
|
||||||
labelProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label || props.name}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<select
|
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
|
||||||
{...props}
|
|
||||||
|
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(
|
className={twMerge(
|
||||||
"w-full pl-3 py-2 border rounded-md appearance-none pr-8",
|
"relative w-full flex items-center border rounded-default",
|
||||||
"border-slate-300 dark:border-white/20",
|
"border-slate-300 dark:border-white/20 pr-2",
|
||||||
"focus:border-slate-700 dark:focus:border-white/50",
|
"focus:border-slate-700 dark:focus:border-white/50",
|
||||||
"outline-slate-300 dark:outline-white/20",
|
"outline-slate-300 dark:outline-white/20",
|
||||||
"focus:outline-slate-700 dark:focus:outline-white/50",
|
"focus:outline-slate-700 dark:focus:outline-white/50",
|
||||||
"bg-white dark:bg-black",
|
"bg-white dark:bg-background-dark",
|
||||||
"twui-select",
|
validity.isValid ? "" : "outline-warning border-warning",
|
||||||
props.className
|
wrapperProps?.className
|
||||||
)}
|
)}
|
||||||
ref={componentRef}
|
|
||||||
value={
|
|
||||||
options.flat().find((opt) => opt.default)?.value ||
|
|
||||||
undefined
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
changeHandler?.(
|
|
||||||
e.target.value as (typeof options)[number]["value"]
|
|
||||||
);
|
|
||||||
props.onChange?.(e);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{options.flat().map((option, index) => {
|
{showLabel && (
|
||||||
return (
|
<label
|
||||||
<option
|
htmlFor={selectID}
|
||||||
key={index}
|
{...labelProps}
|
||||||
value={option.value}
|
className={twMerge(
|
||||||
// selected={option.default || undefined}
|
"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",
|
||||||
{option.title}
|
"twui-input-label",
|
||||||
</option>
|
labelProps?.className
|
||||||
);
|
)}
|
||||||
})}
|
>
|
||||||
</select>
|
{label || props.title || props.name}
|
||||||
|
</label>
|
||||||
<ChevronDown
|
|
||||||
size={20}
|
|
||||||
{...iconProps}
|
|
||||||
className={twMerge(
|
|
||||||
"absolute right-2 pointer-events-none",
|
|
||||||
iconProps?.className
|
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
<select
|
||||||
|
id={selectID}
|
||||||
|
{...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] text-sm p-6">
|
||||||
|
{typeof info == "string" ? (
|
||||||
|
<Span className="text-sm">{info}</Span>
|
||||||
|
) : (
|
||||||
|
info
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!validity.isValid && validity.msg ? (
|
||||||
|
<Span size="smaller" className="text-warning">
|
||||||
|
{validity.msg}
|
||||||
|
</Span>
|
||||||
|
) : undefined}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,42 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
elementRef?: React.MutableRefObject<Element | undefined>;
|
elementRef?: React.RefObject<Element | undefined>;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
elId?: string;
|
||||||
options?: IntersectionObserverInit;
|
options?: IntersectionObserverInit;
|
||||||
removeIntersected?: boolean;
|
removeIntersected?: boolean;
|
||||||
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
export default function useIntersectionObserver({
|
export default function useIntersectionObserver({
|
||||||
elementRef,
|
elementRef,
|
||||||
className,
|
className,
|
||||||
options,
|
options,
|
||||||
removeIntersected,
|
removeIntersected,
|
||||||
|
delay,
|
||||||
|
elId,
|
||||||
}: Param) {
|
}: Param) {
|
||||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||||
const [refresh, setRefresh] = React.useState(0);
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
|
|
||||||
|
const observerTriggerDelay = delay || 200;
|
||||||
|
|
||||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||||
(entries, observer) => {
|
(entries, observer) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
setIsIntersecting(true);
|
timeout = setTimeout(() => {
|
||||||
|
setIsIntersecting(true);
|
||||||
|
|
||||||
if (removeIntersected) {
|
if (removeIntersected) {
|
||||||
observer.unobserve(entry.target);
|
observer.unobserve(entry.target);
|
||||||
}
|
}
|
||||||
|
}, observerTriggerDelay);
|
||||||
} else {
|
} else {
|
||||||
setIsIntersecting(false);
|
setIsIntersecting(false);
|
||||||
}
|
}
|
||||||
@ -34,7 +45,9 @@ export default function useIntersectionObserver({
|
|||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const element = elementRef?.current;
|
const element = elId
|
||||||
|
? document.getElementById(elId)
|
||||||
|
: elementRef?.current;
|
||||||
const elements = className
|
const elements = className
|
||||||
? document.querySelectorAll(`.${className}`)
|
? document.querySelectorAll(`.${className}`)
|
||||||
: null;
|
: null;
|
||||||
|
29
components/lib/hooks/useReady.tsx
Normal file
29
components/lib/hooks/useReady.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeout: any;
|
||||||
|
|
||||||
|
export default function twuiUseReady(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 };
|
||||||
|
}
|
@ -4,12 +4,10 @@ export type UseWebsocketHookParams = {
|
|||||||
debounce?: number;
|
debounce?: number;
|
||||||
url: string;
|
url: string;
|
||||||
disableReconnect?: boolean;
|
disableReconnect?: boolean;
|
||||||
|
keepAliveDuration?: number;
|
||||||
|
refreshConnection?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let reconnectInterval: any;
|
|
||||||
let msgInterval: any;
|
|
||||||
let sendInterval: any;
|
|
||||||
|
|
||||||
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
||||||
|
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
@ -28,8 +26,25 @@ let tries = 0;
|
|||||||
*/
|
*/
|
||||||
export default function useWebSocket<
|
export default function useWebSocket<
|
||||||
T extends { [key: string]: any } = { [key: string]: any }
|
T extends { [key: string]: any } = { [key: string]: any }
|
||||||
>({ url, debounce, disableReconnect }: UseWebsocketHookParams) {
|
>({
|
||||||
|
url,
|
||||||
|
debounce,
|
||||||
|
disableReconnect,
|
||||||
|
keepAliveDuration,
|
||||||
|
refreshConnection,
|
||||||
|
}: UseWebsocketHookParams) {
|
||||||
const DEBOUNCE = debounce || 200;
|
const DEBOUNCE = debounce || 200;
|
||||||
|
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
|
||||||
|
const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3;
|
||||||
|
|
||||||
|
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||||
|
|
||||||
|
let uptime = 0;
|
||||||
|
|
||||||
|
let reconnectInterval: any;
|
||||||
|
let msgInterval: any;
|
||||||
|
let sendInterval: any;
|
||||||
|
let keepAliveInterval: any;
|
||||||
|
|
||||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(
|
const [socket, setSocket] = React.useState<WebSocket | undefined>(
|
||||||
undefined
|
undefined
|
||||||
@ -38,6 +53,9 @@ export default function useWebSocket<
|
|||||||
const messageQueueRef = React.useRef<string[]>([]);
|
const messageQueueRef = React.useRef<string[]>([]);
|
||||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Dispatch Custom Event
|
||||||
|
*/
|
||||||
const dispatchCustomEvent = React.useCallback(
|
const dispatchCustomEvent = React.useCallback(
|
||||||
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
||||||
const event = new CustomEvent(evtName, {
|
const event = new CustomEvent(evtName, {
|
||||||
@ -51,6 +69,9 @@ export default function useWebSocket<
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Connect to Websocket
|
||||||
|
*/
|
||||||
const connect = React.useCallback(() => {
|
const connect = React.useCallback(() => {
|
||||||
const wsURL = url;
|
const wsURL = url;
|
||||||
if (!wsURL) return;
|
if (!wsURL) return;
|
||||||
@ -59,22 +80,41 @@ export default function useWebSocket<
|
|||||||
|
|
||||||
ws.onopen = (ev) => {
|
ws.onopen = (ev) => {
|
||||||
window.clearInterval(reconnectInterval);
|
window.clearInterval(reconnectInterval);
|
||||||
|
window.clearInterval(keepAliveInterval);
|
||||||
|
keepAliveInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(KEEP_ALIVE_MESSAGE);
|
||||||
|
uptime += KEEP_ALIVE_DURATION;
|
||||||
|
if (uptime >= KEEP_ALIVE_TIMEOUT) {
|
||||||
|
console.log("Websocket connection timed out ...");
|
||||||
|
window.clearInterval(keepAliveInterval);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, KEEP_ALIVE_DURATION);
|
||||||
setSocket(ws);
|
setSocket(ws);
|
||||||
tries = 0;
|
tries = 0;
|
||||||
console.log(`Websocket connected to ${wsURL}`);
|
console.log(`Websocket connected to ${wsURL}`);
|
||||||
|
uptime = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
window.clearInterval(msgInterval);
|
window.clearInterval(msgInterval);
|
||||||
messageQueueRef.current.push(ev.data);
|
messageQueueRef.current.push(ev.data);
|
||||||
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||||
|
if (ev.data !== KEEP_ALIVE_MESSAGE) {
|
||||||
|
uptime = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
ws.onclose = (ev) => {
|
||||||
|
console.log("Websocket closed!");
|
||||||
|
|
||||||
if (disableReconnect) return;
|
if (disableReconnect) return;
|
||||||
|
|
||||||
console.log("Websocket closed ... Attempting to reconnect ...");
|
console.log("Attempting to reconnect ...");
|
||||||
console.log("URL:", url);
|
console.log("URL:", url);
|
||||||
|
window.clearInterval(keepAliveInterval);
|
||||||
|
|
||||||
reconnectInterval = setInterval(() => {
|
reconnectInterval = setInterval(() => {
|
||||||
if (tries >= 3) {
|
if (tries >= 3) {
|
||||||
@ -89,6 +129,31 @@ export default function useWebSocket<
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Window Close Handler
|
||||||
|
*/
|
||||||
|
const handleWindowClose = React.useCallback(() => {
|
||||||
|
console.log("Window Unloaded ...");
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Window Focus Handler
|
||||||
|
*/
|
||||||
|
const handleWindowFocus = React.useCallback(() => {
|
||||||
|
if (socket?.readyState === WebSocket.CLOSED) {
|
||||||
|
console.log("Websocket closed ... Attempting to reconnect ...");
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
console.log("Websocket connection alive ...");
|
||||||
|
socket.send(KEEP_ALIVE_MESSAGE);
|
||||||
|
uptime = 0;
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Initial Connection
|
||||||
|
*/
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
@ -97,6 +162,36 @@ export default function useWebSocket<
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Window Close and Focus Handlers
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleWindowClose, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("focus", handleWindowFocus);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
window.removeEventListener("focus", handleWindowFocus);
|
||||||
|
window.removeEventListener("beforeunload", handleWindowClose);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Refresh Connection
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("Refreshing connection ...");
|
||||||
|
|
||||||
|
if (!socket) return;
|
||||||
|
if (socket.readyState !== WebSocket.CLOSED) {
|
||||||
|
socket?.close();
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
}, [refreshConnection]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Received Message Queue Handler
|
* Received Message Queue Handler
|
||||||
*/
|
*/
|
||||||
@ -113,6 +208,7 @@ export default function useWebSocket<
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.clearInterval(msgInterval);
|
window.clearInterval(msgInterval);
|
||||||
|
uptime = 0;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
25
components/lib/layout/ArrowedLink.tsx
Normal file
25
components/lib/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,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
|
ComponentProps,
|
||||||
DetailedHTMLProps,
|
DetailedHTMLProps,
|
||||||
HTMLAttributeAnchorTarget,
|
HTMLAttributeAnchorTarget,
|
||||||
HTMLAttributes,
|
HTMLAttributes,
|
||||||
@ -12,10 +13,13 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
|||||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> & {
|
> & {
|
||||||
|
title: string;
|
||||||
variant?: "normal" | "ghost" | "outlined";
|
variant?: "normal" | "ghost" | "outlined";
|
||||||
color?:
|
color?:
|
||||||
| "primary"
|
| "primary"
|
||||||
| "secondary"
|
| "secondary"
|
||||||
|
| "text"
|
||||||
|
| "white"
|
||||||
| "accent"
|
| "accent"
|
||||||
| "gray"
|
| "gray"
|
||||||
| "error"
|
| "error"
|
||||||
@ -36,6 +40,7 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
|||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
>;
|
>;
|
||||||
|
loadingProps?: ComponentProps<typeof Loading>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,12 +53,48 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
|||||||
* @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,
|
||||||
@ -67,47 +108,67 @@ export default function Button({
|
|||||||
afterIcon,
|
afterIcon,
|
||||||
loading,
|
loading,
|
||||||
loadingIconSize,
|
loadingIconSize,
|
||||||
|
loadingProps,
|
||||||
...props
|
...props
|
||||||
}: TWUIButtonProps) {
|
}: TWUIButtonProps) {
|
||||||
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",
|
||||||
|
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white",
|
||||||
"twui-button-primary"
|
"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",
|
||||||
|
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
|
||||||
"twui-button-gray"
|
"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 dark:text-blue-400 dark:outline-blue-300",
|
"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")
|
||||||
@ -116,23 +177,41 @@ export default function Button({
|
|||||||
"text-slate-600 dark:text-white/60 dark:outline-white/30",
|
"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 dark:bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-blue-500 hover:bg-transparent dark:hover:bg-transparent",
|
"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 dark:bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-emerald-500 hover:bg-transparent dark:hover:bg-transparent",
|
"text-secondary hover:bg-transparent dark:hover:bg-transparent",
|
||||||
|
"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"
|
"twui-button-secondary-ghost"
|
||||||
);
|
);
|
||||||
if (color == "accent")
|
if (color == "accent")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent dark:bg-transparent outline-none p-2",
|
"bg-transparent dark:bg-transparent outline-none p-2",
|
||||||
"text-violet-500 hover:bg-transparent dark:hover:bg-transparent",
|
"text-accent hover:bg-transparent dark:hover:bg-transparent",
|
||||||
"twui-button-accent-ghost"
|
"twui-button-accent-ghost"
|
||||||
);
|
);
|
||||||
if (color == "gray")
|
if (color == "gray")
|
||||||
@ -156,9 +235,15 @@ export default function Button({
|
|||||||
if (color == "success")
|
if (color == "success")
|
||||||
return twMerge(
|
return twMerge(
|
||||||
"bg-transparent outline-none p-2",
|
"bg-transparent outline-none p-2",
|
||||||
"text-emerald-600",
|
"text-success",
|
||||||
"twui-button-success-ghost"
|
"twui-button-success-ghost"
|
||||||
);
|
);
|
||||||
|
if (color == "white")
|
||||||
|
return twMerge(
|
||||||
|
"bg-transparent outline-none p-2",
|
||||||
|
"text-white",
|
||||||
|
"twui-button-white-ghost"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
@ -168,17 +253,24 @@ 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 text-sm",
|
size == "small"
|
||||||
size == "smaller" && "px-2 py-1 text-xs",
|
? "px-3 py-1.5 text-sm twui-button-small"
|
||||||
size == "large" && "text-lg",
|
: size == "smaller"
|
||||||
size == "larger" && "px-5 py-3 text-xl",
|
? "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,
|
||||||
props.className,
|
loading ? "pointer-events-none opacity-80" : "",
|
||||||
loading ? "pointer-events-none opacity-80" : "l"
|
props.className
|
||||||
)}
|
)}
|
||||||
|
aria-label={props.title}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
{...buttonContentProps}
|
{...buttonContentProps}
|
||||||
@ -196,7 +288,6 @@ export default function Button({
|
|||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Loading
|
<Loading
|
||||||
className="absolute"
|
|
||||||
size={(() => {
|
size={(() => {
|
||||||
if (loadingIconSize) return loadingIconSize;
|
if (loadingIconSize) return loadingIconSize;
|
||||||
switch (size) {
|
switch (size) {
|
||||||
@ -209,6 +300,8 @@ export default function Button({
|
|||||||
return "normal";
|
return "normal";
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
{...loadingProps}
|
||||||
|
className={twMerge("absolute", loadingProps?.className)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -216,7 +309,13 @@ export default function 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>
|
||||||
);
|
);
|
||||||
|
@ -13,7 +13,7 @@ export default function Center({
|
|||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col items-center justify-center gap-4 p-2 w-full",
|
"flex flex-col items-center justify-center gap-4 p-2 w-full",
|
||||||
"twui-center",
|
"h-full twui-center",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -1,28 +1,32 @@
|
|||||||
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-horizontal
|
||||||
* @className twui-divider-vertical
|
* @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}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"border-slate-200 dark:border-white/10 border-solid",
|
"border-slate-200 dark:border-white/10",
|
||||||
vertical
|
vertical
|
||||||
? "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",
|
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
|
||||||
|
dashed ? "border-dashed" : "border-solid",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
17
components/lib/layout/DocsImg.tsx
Normal file
17
components/lib/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(
|
||||||
|
"text-sm mb-4",
|
||||||
|
"twui-headings twui-heading",
|
||||||
|
"twui-h5",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h5>
|
</h5>
|
||||||
|
38
components/lib/layout/IconLink.tsx
Normal file
38
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
@ -6,6 +6,7 @@ export type TWUIImageProps = DetailedHTMLProps<
|
|||||||
ImgHTMLAttributes<HTMLImageElement>,
|
ImgHTMLAttributes<HTMLImageElement>,
|
||||||
HTMLImageElement
|
HTMLImageElement
|
||||||
> & {
|
> & {
|
||||||
|
alt: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
circle?: boolean;
|
circle?: boolean;
|
||||||
bgImg?: boolean;
|
bgImg?: boolean;
|
||||||
@ -24,6 +25,8 @@ export default function Img({ ...props }: TWUIImageProps) {
|
|||||||
const height = props.size || props.height;
|
const height = props.size || props.height;
|
||||||
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
||||||
|
|
||||||
|
const [imageError, setImageError] = React.useState(false);
|
||||||
|
|
||||||
const finalProps = _.omit(props, [
|
const finalProps = _.omit(props, [
|
||||||
"size",
|
"size",
|
||||||
"circle",
|
"circle",
|
||||||
@ -53,30 +56,70 @@ export default function Img({ ...props }: TWUIImageProps) {
|
|||||||
}
|
}
|
||||||
props.onError?.(e);
|
props.onError?.(e);
|
||||||
},
|
},
|
||||||
|
style: {
|
||||||
|
...(props.size
|
||||||
|
? {
|
||||||
|
width: `${props.size}px`,
|
||||||
|
minWidth: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...props.style,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (imageError) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
{...interpolatedProps}
|
||||||
|
src={
|
||||||
|
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (props.srcDark && props.srcLight) {
|
if (props.srcDark && props.srcLight) {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<img
|
<img
|
||||||
|
loading="lazy"
|
||||||
{...interpolatedProps}
|
{...interpolatedProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"hidden dark:block",
|
"hidden dark:block",
|
||||||
interpolatedProps.className
|
interpolatedProps.className
|
||||||
)}
|
)}
|
||||||
src={props.srcDark}
|
src={props.srcDark}
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
props.onError?.(e);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
|
loading="lazy"
|
||||||
{...interpolatedProps}
|
{...interpolatedProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"block dark:hidden",
|
"block dark:hidden",
|
||||||
interpolatedProps.className
|
interpolatedProps.className
|
||||||
)}
|
)}
|
||||||
src={props.srcLight}
|
src={props.srcLight}
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
props.onError?.(e);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <img {...interpolatedProps} />;
|
return (
|
||||||
|
<img
|
||||||
|
{...interpolatedProps}
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
props.onError?.(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,29 +9,41 @@ type Props = DetailedHTMLProps<
|
|||||||
showArrow?: boolean;
|
showArrow?: boolean;
|
||||||
arrowSize?: number;
|
arrowSize?: number;
|
||||||
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
|
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,
|
showArrow,
|
||||||
arrowSize = 20,
|
arrowSize = 20,
|
||||||
arrowProps,
|
arrowProps,
|
||||||
|
strict,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
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 hover:opacity-60 transition-all",
|
"text-link dark:text-link-dark hover:opacity-80 transition-all",
|
||||||
"border-0 border-b border-blue-300 dark:border-blue-200/30 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 && (
|
{showArrow && (
|
||||||
|
@ -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/lib/layout/Spacer.tsx
Normal file
30
components/lib/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(
|
||||||
|
"grow",
|
||||||
|
horizontal ? "w-10" : "w-full h-10",
|
||||||
|
"twui-spacer",
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -17,7 +17,7 @@ export default function Span({
|
|||||||
<span
|
<span
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-base",
|
"",
|
||||||
size == "small" && "text-sm",
|
size == "small" && "text-sm",
|
||||||
size == "smaller" && "text-xs",
|
size == "smaller" && "text-xs",
|
||||||
size == "large" && "text-lg",
|
size == "large" && "text-lg",
|
||||||
|
37
components/lib/layout/Stack copy.tsx
Normal file
37
components/lib/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>
|
||||||
|
);
|
||||||
|
}
|
@ -7,13 +7,15 @@ type Props = DetailedHTMLProps<
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
center?: boolean;
|
center?: boolean;
|
||||||
|
gap?: number | string;
|
||||||
|
componentRef?: React.RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Flexbox Column
|
* # Flexbox Column
|
||||||
* @className twui-stack
|
* @className twui-stack
|
||||||
*/
|
*/
|
||||||
export default function Stack({ ...props }: Props) {
|
export default function Stack({ gap, componentRef, ...props }: Props) {
|
||||||
const finalProps = _.omit(props, "center");
|
const finalProps = _.omit(props, "center");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -21,9 +23,15 @@ export default function Stack({ ...props }: Props) {
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex flex-col items-start gap-4",
|
"flex flex-col items-start gap-4",
|
||||||
props.center && "items-center",
|
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>
|
||||||
|
88
components/lib/mdx/markdown/MarkdownEditor.tsx
Normal file
88
components/lib/mdx/markdown/MarkdownEditor.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React, { ComponentProps } 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";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string;
|
||||||
|
setValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
defaultSideBySide?: boolean;
|
||||||
|
changeHandler?: (content: string) => void;
|
||||||
|
editorProps?: ComponentProps<typeof AceEditor>;
|
||||||
|
maxHeight?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownEditor({
|
||||||
|
value: existingValue,
|
||||||
|
setValue: setExistingValue,
|
||||||
|
defaultSideBySide,
|
||||||
|
changeHandler,
|
||||||
|
editorProps,
|
||||||
|
maxHeight: existingMaxHeight,
|
||||||
|
}: Props) {
|
||||||
|
const [value, setValue] = React.useState<any>(existingValue || ``);
|
||||||
|
|
||||||
|
const [sideBySide, setSideBySide] = React.useState(
|
||||||
|
defaultSideBySide || false
|
||||||
|
);
|
||||||
|
const [preview, setPreview] = React.useState(false);
|
||||||
|
|
||||||
|
const maxHeight = existingMaxHeight || "600px";
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setExistingValue?.(value);
|
||||||
|
changeHandler?.(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full items-stretch">
|
||||||
|
<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}
|
||||||
|
{...editorProps}
|
||||||
|
/>
|
||||||
|
<MarkdownEditorPreviewComponent
|
||||||
|
setValue={setValue}
|
||||||
|
value={value}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Stack
|
||||||
|
className={twMerge(`w-full max-h-[${maxHeight}] h-full`)}
|
||||||
|
>
|
||||||
|
{preview ? (
|
||||||
|
<MarkdownEditorPreviewComponent
|
||||||
|
setValue={setValue}
|
||||||
|
value={value}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownEditorComponent
|
||||||
|
setValue={setValue}
|
||||||
|
value={value}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
{...editorProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
32
components/lib/mdx/markdown/MarkdownEditorComponent.tsx
Normal file
32
components/lib/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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
28
components/lib/mdx/markdown/MarkdownEditorDeprecated.tsx
Normal file
28
components/lib/mdx/markdown/MarkdownEditorDeprecated.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import MDEditor from "@uiw/react-md-editor";
|
||||||
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
|
import React from "react";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypePrismPlus from "rehype-prism-plus";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
|
export default function MarkdownEditor() {
|
||||||
|
const [value, setValue] = React.useState<any>(
|
||||||
|
`**Hello world!!!** <IFRAME SRC=\"javascript:javascript:alert(window.origin);\"></IFRAME>`
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("value", value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
previewOptions={{
|
||||||
|
rehypePlugins: [rehypeSanitize, rehypePrismPlus],
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.getElementById("markdown-modal-root") as HTMLElement
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import React 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;
|
||||||
|
setValue: React.Dispatch<any>;
|
||||||
|
maxHeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownEditorPreviewComponent({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
maxHeight,
|
||||||
|
}: 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) => {
|
||||||
|
console.log(`Markdown Parsing Error => ${err.message}`);
|
||||||
|
});
|
||||||
|
} catch (error) {}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Border
|
||||||
|
className={twMerge(
|
||||||
|
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
|
||||||
|
"overflow-auto"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mdxSource ? (
|
||||||
|
<MDXRemote
|
||||||
|
{...mdxSource}
|
||||||
|
components={{
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Border>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return <EmptyContent title={`Markdown Syntax Error.`} />;
|
||||||
|
}
|
||||||
|
}
|
111
components/lib/mdx/markdown/MarkdownEditorSelectorButtons.tsx
Normal file
111
components/lib/mdx/markdown/MarkdownEditorSelectorButtons.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Button from "../../layout/Button";
|
||||||
|
import Border from "../../elements/Border";
|
||||||
|
import { LocalStorageDict } from "@/package-shared/dict/local-storage-dict";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
|
||||||
|
if (sideBySide) {
|
||||||
|
localStorage.setItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultSideBySide"],
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultSideBySide"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
localStorage.setItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultPreview"],
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultPreview"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [sideBySide, preview, ready]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
localStorage.getItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultPreview"]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setPreview(true);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!localStorage.getItem(
|
||||||
|
LocalStorageDict["MarkdownEditorDefaultSideBySide"]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
77
components/lib/mdx/mdx-components.tsx
Normal file
77
components/lib/mdx/mdx-components.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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 (
|
||||||
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{children.props.children}
|
||||||
|
</CodeBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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) => (
|
||||||
|
<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)",
|
||||||
|
};
|
||||||
|
}
|
@ -5,6 +5,7 @@ import H2 from "../../layout/H2";
|
|||||||
import H3 from "../../layout/H3";
|
import H3 from "../../layout/H3";
|
||||||
import H4 from "../../layout/H4";
|
import H4 from "../../layout/H4";
|
||||||
import CodeBlock from "../../elements/CodeBlock";
|
import CodeBlock from "../../elements/CodeBlock";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
components: MDXComponents;
|
components: MDXComponents;
|
||||||
@ -17,13 +18,12 @@ export default function useMDXComponents({
|
|||||||
}: Params): MDXComponents {
|
}: Params): MDXComponents {
|
||||||
return {
|
return {
|
||||||
h1: ({ children }) => <H1>{children}</H1>,
|
h1: ({ children }) => <H1>{children}</H1>,
|
||||||
h2: ({ children }) => <H2>{children}</H2>,
|
|
||||||
h3: ({ children }) => <H3>{children}</H3>,
|
|
||||||
h4: ({ children }) => <H4>{children}</H4>,
|
h4: ({ children }) => <H4>{children}</H4>,
|
||||||
pre: ({ children, ...props }) => {
|
pre: ({ children, ...props }) => {
|
||||||
if (React.isValidElement(children) && children.props) {
|
if (React.isValidElement(children) && children.props) {
|
||||||
return (
|
return (
|
||||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
{children.props.children}
|
{children.props.children}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
|
@ -2,26 +2,30 @@
|
|||||||
"name": "tailwind-ui",
|
"name": "tailwind-ui",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "latest",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "latest",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "latest",
|
||||||
"react": "^19.0.0",
|
"react-code-blocks": "latest",
|
||||||
"react-code-blocks": "^0.1.6",
|
"react-responsive-modal": "latest",
|
||||||
"react-dom": "^19.0.0",
|
"tailwind-merge": "latest",
|
||||||
"react-responsive-modal": "^6.4.2",
|
"typescript": "latest",
|
||||||
"tailwind-merge": "^2.6.0",
|
"mdx/types": "latest",
|
||||||
"typescript": "^5.7.3"
|
"gray-matter": "latest",
|
||||||
|
"next-mdx-remote": "latest",
|
||||||
|
"remark-gfm": "latest",
|
||||||
|
"rehype-prism-plus": "latest",
|
||||||
|
"html-to-react": "^1.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ace": "^0.0.52",
|
"@types/ace": "latest",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "latest",
|
||||||
"@types/node": "^20.17.16",
|
"@types/node": "latest",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "latest",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "latest",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "latest",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "latest",
|
||||||
"@next/mdx": "^15.1.5"
|
"@next/mdx": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
68
components/lib/types.ts
Normal file
68
components/lib/types.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export const AutocompleteOptions = [
|
||||||
|
// Personal Information
|
||||||
|
"name",
|
||||||
|
"honorific-prefix",
|
||||||
|
"given-name",
|
||||||
|
"additional-name",
|
||||||
|
"family-name",
|
||||||
|
"honorific-suffix",
|
||||||
|
"nickname",
|
||||||
|
"phone",
|
||||||
|
|
||||||
|
// Contact Information
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"new-password",
|
||||||
|
"current-password",
|
||||||
|
"one-time-code",
|
||||||
|
"organization-title",
|
||||||
|
"organization",
|
||||||
|
|
||||||
|
// Address Fields
|
||||||
|
"street-address",
|
||||||
|
"address-line1",
|
||||||
|
"address-line2",
|
||||||
|
"address-line3",
|
||||||
|
"address-level4",
|
||||||
|
"address-level3",
|
||||||
|
"address-level2",
|
||||||
|
"address-level1",
|
||||||
|
"country",
|
||||||
|
"country-name",
|
||||||
|
"postal-code",
|
||||||
|
|
||||||
|
// Phone Numbers
|
||||||
|
"tel",
|
||||||
|
"tel-country-code",
|
||||||
|
"tel-national",
|
||||||
|
"tel-area-code",
|
||||||
|
"tel-local",
|
||||||
|
"tel-extension",
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
"bday",
|
||||||
|
"bday-day",
|
||||||
|
"bday-month",
|
||||||
|
"bday-year",
|
||||||
|
|
||||||
|
// Payment Information
|
||||||
|
"cc-name",
|
||||||
|
"cc-given-name",
|
||||||
|
"cc-additional-name",
|
||||||
|
"cc-family-name",
|
||||||
|
"cc-number",
|
||||||
|
"cc-exp",
|
||||||
|
"cc-exp-month",
|
||||||
|
"cc-exp-year",
|
||||||
|
"cc-csc",
|
||||||
|
"cc-type",
|
||||||
|
|
||||||
|
// Additional Options
|
||||||
|
"sex",
|
||||||
|
"url",
|
||||||
|
"photo",
|
||||||
|
|
||||||
|
// Special Values
|
||||||
|
"on", // Enables autofill (default)
|
||||||
|
"off", // Disables autofill
|
||||||
|
] as const;
|
6
components/lib/utils/camel-to-normal-case.ts
Normal file
6
components/lib/utils/camel-to-normal-case.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default function twuiCamelToNormalCase(str: string) {
|
||||||
|
return str
|
||||||
|
.replace(/([A-Z])/g, " $1")
|
||||||
|
.trim()
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
@ -49,14 +49,15 @@ export default async function fetchApi<
|
|||||||
): Promise<R> {
|
): Promise<R> {
|
||||||
let data;
|
let data;
|
||||||
|
|
||||||
const csrfValue = localStorage.getItem(localStorageCSRFKey || "csrf");
|
const csrfKey = "x-dsql-csrf-key";
|
||||||
|
const csrfValue = localStorage.getItem(localStorageCSRFKey || csrfKey);
|
||||||
|
|
||||||
let finalHeaders = {
|
let finalHeaders = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
} as FetchHeader;
|
} as FetchHeader;
|
||||||
|
|
||||||
if (csrf && csrfValue) {
|
if (csrf && csrfValue) {
|
||||||
finalHeaders[csrfHeaderKey || "x-csrf-key"] = csrfValue;
|
finalHeaders[localStorageCSRFKey || csrfKey] = csrfValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof options === "string") {
|
if (typeof options === "string") {
|
||||||
|
@ -2,36 +2,38 @@ export type ImageInputToBase64FunctionReturn = {
|
|||||||
imageBase64?: string;
|
imageBase64?: string;
|
||||||
imageBase64Full?: string;
|
imageBase64Full?: string;
|
||||||
imageName?: string;
|
imageName?: string;
|
||||||
|
imageType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImageInputToBase64FunctioParam = {
|
export type ImageInputToBase64FunctioParam = {
|
||||||
imageInput: HTMLInputElement;
|
imageInput?: HTMLInputElement;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
file?: File;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function imageInputToBase64({
|
export default async function imageInputToBase64({
|
||||||
imageInput,
|
imageInput,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
file,
|
||||||
}: ImageInputToBase64FunctioParam): Promise<ImageInputToBase64FunctionReturn> {
|
}: ImageInputToBase64FunctioParam): Promise<ImageInputToBase64FunctionReturn> {
|
||||||
try {
|
try {
|
||||||
if (!imageInput.files?.[0]) {
|
const finalFile = file || imageInput?.files?.[0];
|
||||||
throw new Error("No Files found in this image input");
|
|
||||||
|
if (!finalFile) {
|
||||||
|
throw new Error("No Files found");
|
||||||
}
|
}
|
||||||
let imagePreviewNode = document.querySelector(
|
|
||||||
`[data-imagepreview='image']`
|
let imageName = finalFile.name.replace(/\..*/, "");
|
||||||
);
|
|
||||||
let imageName = imageInput.files[0].name.replace(/\..*/, "");
|
|
||||||
|
|
||||||
let imageDataBase64: string | undefined;
|
let imageDataBase64: string | undefined;
|
||||||
|
|
||||||
const MIME_TYPE = mimeType ? mimeType : "image/jpeg";
|
const MIME_TYPE = mimeType ? mimeType : finalFile.type;
|
||||||
const QUALITY = 0.95;
|
const QUALITY = 0.95;
|
||||||
const MAX_WIDTH = maxWidth ? maxWidth : null;
|
const MAX_WIDTH = maxWidth ? maxWidth : null;
|
||||||
|
|
||||||
const file = imageInput.files[0];
|
const blobURL = URL.createObjectURL(finalFile);
|
||||||
const blobURL = URL.createObjectURL(file);
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.src = blobURL;
|
img.src = blobURL;
|
||||||
@ -76,6 +78,7 @@ export default async function imageInputToBase64({
|
|||||||
imageBase64: imageDataBase64?.replace(/.*?base64,/, ""),
|
imageBase64: imageDataBase64?.replace(/.*?base64,/, ""),
|
||||||
imageBase64Full: imageDataBase64,
|
imageBase64Full: imageDataBase64,
|
||||||
imageName: imageName,
|
imageName: imageName,
|
||||||
|
imageType: MIME_TYPE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log("Image Processing Error! =>", error.message);
|
console.log("Image Processing Error! =>", error.message);
|
||||||
@ -87,7 +90,3 @@ export default async function imageInputToBase64({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ********************************************** */
|
|
||||||
/** ********************************************** */
|
|
||||||
/** ********************************************** */
|
|
||||||
|
6
components/lib/utils/normalize-text.ts
Normal file
6
components/lib/utils/normalize-text.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default function twuiNormalizeText(txt: string) {
|
||||||
|
return txt
|
||||||
|
.replace(/\n|\r|\n\r/g, " ")
|
||||||
|
.replace(/ {2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
31
components/lib/utils/numberfy.ts
Normal file
31
components/lib/utils/numberfy.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export default function twuiNumberfy(num: any, decimals?: number): number {
|
||||||
|
try {
|
||||||
|
const numberString = String(num)
|
||||||
|
.replace(/[^0-9\.]/g, "")
|
||||||
|
.replace(/\.$/, "");
|
||||||
|
|
||||||
|
if (!numberString.match(/./)) return 0;
|
||||||
|
|
||||||
|
const existingDecimals = numberString.match(/\./)
|
||||||
|
? numberString.split(".").pop()?.length
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const numberfiedNum = Number(numberString);
|
||||||
|
|
||||||
|
if (typeof numberfiedNum !== "number") return 0;
|
||||||
|
if (isNaN(numberfiedNum)) return 0;
|
||||||
|
|
||||||
|
if (decimals == 0) {
|
||||||
|
return Math.round(Number(numberfiedNum));
|
||||||
|
} else if (decimals) {
|
||||||
|
return Number(numberfiedNum.toFixed(decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingDecimals)
|
||||||
|
return Number(numberfiedNum.toFixed(existingDecimals));
|
||||||
|
return Math.round(numberfiedNum);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`Numberfy ERROR: ${error.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
15
components/lib/utils/slug-to-normal-text.ts
Normal file
15
components/lib/utils/slug-to-normal-text.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export default function twuiSlugToNormalText(str?: string) {
|
||||||
|
if (!str) return "";
|
||||||
|
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ /g, "-")
|
||||||
|
.replace(/[^a-z0-9\-]/g, "-")
|
||||||
|
.replace(/-{2,}/g, "-")
|
||||||
|
.replace(/[-]/g, " ")
|
||||||
|
.split(" ")
|
||||||
|
.map(
|
||||||
|
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
}
|
37
components/lib/utils/slugify.ts
Normal file
37
components/lib/utils/slugify.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* # Return the slug of a string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* slugify("Hello World") // "hello-world"
|
||||||
|
* slugify("Yes!") // "yes"
|
||||||
|
* slugify("Hello!!! World!") // "hello-world"
|
||||||
|
*/
|
||||||
|
export default function twuiSlugify(
|
||||||
|
str?: string,
|
||||||
|
divider?: "-" | "_" | null,
|
||||||
|
allowTrailingDash?: boolean | null
|
||||||
|
): string {
|
||||||
|
const finalSlugDivider = divider || "-";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!str) return "";
|
||||||
|
|
||||||
|
let finalStr = String(str)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ {2,}/g, " ")
|
||||||
|
.replace(/ /g, finalSlugDivider)
|
||||||
|
.replace(/[^a-z0-9]/g, finalSlugDivider)
|
||||||
|
.replace(/-{2,}|_{2,}/g, finalSlugDivider)
|
||||||
|
.replace(/^-/, "");
|
||||||
|
|
||||||
|
if (allowTrailingDash) {
|
||||||
|
return finalStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalStr.replace(/-$/, "");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`Slugify ERROR: ${error.message}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
25
components/pages/blog/(sections)/BlogPostsList.tsx
Normal file
25
components/pages/blog/(sections)/BlogPostsList.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import EmptyContent from "@/components/lib/elements/EmptyContent";
|
||||||
|
import Section from "@/components/lib/layout/Section";
|
||||||
|
import { AppContext } from "@/pages/_app";
|
||||||
|
import React from "react";
|
||||||
|
import BlogPostsListCard from "./BlogPostsListCard";
|
||||||
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
|
|
||||||
|
export default function BlogPostsList() {
|
||||||
|
const { pageProps } = React.useContext(AppContext);
|
||||||
|
const { blogPosts } = pageProps;
|
||||||
|
|
||||||
|
if (!blogPosts?.[0]) {
|
||||||
|
return <EmptyContent title={`No Blog Posts at this moment`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section className="!mt-0 !pt-0">
|
||||||
|
<Stack className="w-full">
|
||||||
|
{blogPosts.map((post, index) => {
|
||||||
|
return <BlogPostsListCard post={post} key={index} />;
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
42
components/pages/blog/(sections)/BlogPostsListCard.tsx
Normal file
42
components/pages/blog/(sections)/BlogPostsListCard.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Card from "@/components/lib/elements/Card";
|
||||||
|
import H2 from "@/components/lib/layout/H2";
|
||||||
|
import Row from "@/components/lib/layout/Row";
|
||||||
|
import Span from "@/components/lib/layout/Span";
|
||||||
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
|
import { DSQL_TBENME_BLOG_POSTS } from "@/types";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
CircleArrowOutUpRight,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: DSQL_TBENME_BLOG_POSTS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BlogPostsListCard({ post }: Props) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
linkProps={{ className: "p-0 !text-white" }}
|
||||||
|
>
|
||||||
|
<Row className="w-full justify-between">
|
||||||
|
<Stack className="gap-1">
|
||||||
|
<H2>{post.title}</H2>
|
||||||
|
<Span size="large" className="mb-2">
|
||||||
|
{post.excerpt}
|
||||||
|
</Span>
|
||||||
|
<Row>
|
||||||
|
<Clock size={13} opacity={0.4} />
|
||||||
|
<Span size="smaller" variant="faded">
|
||||||
|
{post.date_created?.substring(0, 24)}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
</Stack>
|
||||||
|
<ArrowUpRight />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
24
components/pages/blog/(sections)/Hero.tsx
Normal file
24
components/pages/blog/(sections)/Hero.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import EmptyContent from "@/components/lib/elements/EmptyContent";
|
||||||
|
import H1 from "@/components/lib/layout/H1";
|
||||||
|
import Section from "@/components/lib/layout/Section";
|
||||||
|
import Span from "@/components/lib/layout/Span";
|
||||||
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
|
import { AppContext } from "@/pages/_app";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Hero() {
|
||||||
|
const { pageProps } = React.useContext(AppContext);
|
||||||
|
const { blogPosts } = pageProps;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<Stack className="w-full gap-0">
|
||||||
|
<H1 className="leading-snug">Tech Musings</H1>
|
||||||
|
<Span>
|
||||||
|
A few takes and tips from my encyclopedia of knowledge in
|
||||||
|
the tech industry
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
12
components/pages/blog/index.tsx
Normal file
12
components/pages/blog/index.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Hero from "./(sections)/Hero";
|
||||||
|
import BlogPostsList from "./(sections)/BlogPostsList";
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Hero />
|
||||||
|
<BlogPostsList />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
65
components/pages/blog/slug/index.tsx
Normal file
65
components/pages/blog/slug/index.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import Breadcrumbs from "@/components/lib/elements/Breadcrumbs";
|
||||||
|
import EmptyContent from "@/components/lib/elements/EmptyContent";
|
||||||
|
import Divider from "@/components/lib/layout/Divider";
|
||||||
|
import H1 from "@/components/lib/layout/H1";
|
||||||
|
import Row from "@/components/lib/layout/Row";
|
||||||
|
import Section from "@/components/lib/layout/Section";
|
||||||
|
import Span from "@/components/lib/layout/Span";
|
||||||
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
|
import { useMDXComponents } from "@/components/lib/mdx/mdx-components";
|
||||||
|
import { AppContext } from "@/pages/_app";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { MDXRemote } from "next-mdx-remote";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Main() {
|
||||||
|
const { pageProps } = React.useContext(AppContext);
|
||||||
|
const { blogPost } = pageProps;
|
||||||
|
|
||||||
|
const mdxSource = pageProps.mdxSource;
|
||||||
|
const { components, codeBgColor } = useMDXComponents();
|
||||||
|
|
||||||
|
if (!mdxSource) {
|
||||||
|
return <EmptyContent title="No Content For this Post" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<Stack className="w-full max-w-full xl:max-w-[800px]">
|
||||||
|
{/* <Divider dashed className="border-[2px] my-6" /> */}
|
||||||
|
<Stack className="gap-1">
|
||||||
|
<H1 className="leading-snug mb-1">{blogPost?.title}</H1>
|
||||||
|
<Span>{blogPost?.excerpt}</Span>
|
||||||
|
<Stack className="gap-2 mt-2">
|
||||||
|
<Breadcrumbs
|
||||||
|
divider={<span className="opacity-40">/</span>}
|
||||||
|
linkProps={{
|
||||||
|
className: "!text-white border-none",
|
||||||
|
}}
|
||||||
|
currentLinkProps={{
|
||||||
|
className: " opacity-40 pointer-events-none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Row className="mt-1">
|
||||||
|
<Clock
|
||||||
|
size={13}
|
||||||
|
opacity={0.5}
|
||||||
|
className="-mt-[1px]"
|
||||||
|
/>
|
||||||
|
<Span size="smaller" variant="faded">
|
||||||
|
{blogPost?.date_created?.substring(0, 24)}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
<Divider dashed className="border-[2px] my-6" />
|
||||||
|
<MDXRemote
|
||||||
|
{...mdxSource}
|
||||||
|
components={{
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
@ -1,8 +1,25 @@
|
|||||||
import type { NextConfig } from "next";
|
import createMDX from "@next/mdx";
|
||||||
|
import { NextConfig } from "next";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypePrismPlus from "rehype-prism-plus";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
reactStrictMode: true,
|
||||||
reactStrictMode: true,
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
const withMDX = createMDX({
|
||||||
|
extension: /\.mdx?$/,
|
||||||
|
options: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
rehypePlugins: [rehypePrismPlus],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withMDX(nextConfig);
|
||||||
|
10
package.json
10
package.json
@ -10,22 +10,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moduletrace/buncid": "^1.0.7",
|
"@moduletrace/buncid": "^1.0.7",
|
||||||
"@moduletrace/datasquirel": "^2.7.4",
|
"@moduletrace/datasquirel": "^5.1.0",
|
||||||
"@moduletrace/twui": "file:./components/lib",
|
"@moduletrace/twui": "file:./components/lib",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next": "15.0.3",
|
"next": "15.0.3",
|
||||||
|
"next-mdx-remote": "^5.0.0",
|
||||||
|
"prism-themes": "^1.9.0",
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"rehype-prism-plus": "^2.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^2.5.5"
|
"tailwind-merge": "^2.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/lodash": "^4.17.13",
|
"@types/lodash": "^4.17.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,25 @@
|
|||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
import { PagePropsType } from "@/types";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
|
import "prism-themes/themes/prism-dracula.css";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type AppContextType = {
|
||||||
|
pageProps: PagePropsType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppContext = React.createContext<AppContextType>({
|
||||||
|
pageProps: {},
|
||||||
|
});
|
||||||
|
|
||||||
export default function App({ Component, pageProps }: AppProps) {
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
return <Component {...pageProps} />;
|
return (
|
||||||
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
pageProps,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
91
pages/blog/[slug]/index.tsx
Normal file
91
pages/blog/[slug]/index.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import Layout from "@/layouts/main";
|
||||||
|
import Main from "@/components/pages/blog/slug";
|
||||||
|
import { GetStaticPaths, GetStaticProps } from "next";
|
||||||
|
import datasquirel from "@moduletrace/datasquirel";
|
||||||
|
import { DSQL_TBENME_BLOG_POSTS, PagePropsType } from "@/types";
|
||||||
|
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
|
||||||
|
import { serialize } from "next-mdx-remote/serialize";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypePrismPlus from "rehype-prism-plus";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
|
||||||
|
export default function SingleBlogPost() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Main />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
|
||||||
|
const blogPostRes: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> =
|
||||||
|
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({
|
||||||
|
action: "get",
|
||||||
|
table: "blog_posts",
|
||||||
|
query: {
|
||||||
|
query: {
|
||||||
|
slug: {
|
||||||
|
value: ctx.params?.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const singleBlogPost = blogPostRes.payload?.[0] || null;
|
||||||
|
|
||||||
|
if (!singleBlogPost?.body) {
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMdString = singleBlogPost.body;
|
||||||
|
|
||||||
|
const pageGrayMatter = matter(pageMdString);
|
||||||
|
|
||||||
|
const data = pageGrayMatter.data;
|
||||||
|
const content = pageGrayMatter.content;
|
||||||
|
|
||||||
|
const mdxSource = await serialize(content, {
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
rehypePlugins: [rehypePrismPlus],
|
||||||
|
},
|
||||||
|
scope: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
blogPost: singleBlogPost,
|
||||||
|
mdxSource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStaticPaths: GetStaticPaths = async (ctx) => {
|
||||||
|
const blogPostRes: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> =
|
||||||
|
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({
|
||||||
|
action: "get",
|
||||||
|
table: "blog_posts",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogPosts = blogPostRes.payload;
|
||||||
|
|
||||||
|
if (!blogPosts?.[0]) {
|
||||||
|
return {
|
||||||
|
paths: [],
|
||||||
|
fallback: "blocking",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
paths: blogPosts.map((post) => {
|
||||||
|
return {
|
||||||
|
params: {
|
||||||
|
slug: post.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
fallback: "blocking",
|
||||||
|
};
|
||||||
|
};
|
34
pages/blog/index.tsx
Normal file
34
pages/blog/index.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import Layout from "@/layouts/main";
|
||||||
|
import Main from "@/components/pages/blog";
|
||||||
|
import { GetStaticProps } from "next";
|
||||||
|
import datasquirel from "@moduletrace/datasquirel";
|
||||||
|
import { DSQL_TBENME_BLOG_POSTS, PagePropsType } from "@/types";
|
||||||
|
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
|
||||||
|
|
||||||
|
export default function BlogPage() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Main />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
|
||||||
|
const blogPosts: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> =
|
||||||
|
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({
|
||||||
|
action: "get",
|
||||||
|
table: "blog_posts",
|
||||||
|
query: {
|
||||||
|
order: {
|
||||||
|
field: "id",
|
||||||
|
strategy: "DESC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
blogPosts: blogPosts.payload || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -1,8 +1,5 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: ["@tailwindcss/postcss"],
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
@tailwind base;
|
@import "../components/lib/base.css";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #02030f;
|
|
||||||
--header-height: 78px;
|
--header-height: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@theme inline {
|
||||||
@apply bg-[var(--bg-color)] text-white;
|
--color-primary: #02030f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twui-button-general {
|
.twui-button-general {
|
||||||
@ -57,3 +54,7 @@ body {
|
|||||||
.twui-button-primary-ghost {
|
.twui-button-primary-ghost {
|
||||||
@apply bg-transparent text-white;
|
@apply bg-transparent text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twui-card-link {
|
||||||
|
@apply p-0 w-full border-none;
|
||||||
|
}
|
||||||
|
22
types.ts
Normal file
22
types.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||||
|
|
||||||
|
export type PagePropsType = {
|
||||||
|
blogPosts?: DSQL_TBENME_BLOG_POSTS[] | null;
|
||||||
|
blogPost?: DSQL_TBENME_BLOG_POSTS | null;
|
||||||
|
mdxSource?: MDXRemoteSerializeResult<any, any> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DSQL_TBENME_BLOG_POSTS = {
|
||||||
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
body?: string;
|
||||||
|
metadata?: string;
|
||||||
|
date_created?: string;
|
||||||
|
date_created_code?: number;
|
||||||
|
date_created_timestamp?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
date_updated_code?: number;
|
||||||
|
date_updated_timestamp?: string;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user