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 (
|
||||
<aside
|
||||
{...props}
|
||||
className={twMerge("py-10 hidden xl:flex", props.className)}
|
||||
className={twMerge(
|
||||
"pb-10 hidden xl:flex sticky top-6",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
{before}
|
||||
|
@ -8,7 +8,7 @@ import Stack from "../../layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../../layout/Row";
|
||||
import Divider from "../../layout/Divider";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Circle } from "lucide-react";
|
||||
import Button from "../../layout/Button";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
@ -20,6 +20,7 @@ type Props = DetailedHTMLProps<
|
||||
strict?: boolean;
|
||||
childWrapperProps?: ComponentProps<typeof Stack>;
|
||||
autoExpandAll?: boolean;
|
||||
child?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -34,6 +35,7 @@ export default function TWUIDocsLink({
|
||||
childWrapperProps,
|
||||
strict,
|
||||
autoExpandAll,
|
||||
child,
|
||||
...props
|
||||
}: Props) {
|
||||
const [isActive, setIsActive] = React.useState(false);
|
||||
@ -68,16 +70,19 @@ export default function TWUIDocsLink({
|
||||
{...wrapperProps}
|
||||
>
|
||||
<Row className="flex-nowrap grow justify-between w-full">
|
||||
{child && <Circle size={6} />}
|
||||
<a
|
||||
href={docLink.href}
|
||||
title={docLink.title}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"twui-docs-left-aside-link whitespace-nowrap",
|
||||
"grow",
|
||||
"grow overflow-hidden overflow-ellipsis",
|
||||
isActive ? "active" : "",
|
||||
props.className
|
||||
)}
|
||||
ref={linkRef}
|
||||
data-strict={strict || docLink.strict}
|
||||
>
|
||||
{docLink.title}
|
||||
</a>
|
||||
@ -91,6 +96,7 @@ export default function TWUIDocsLink({
|
||||
expand ? "rotate-180 opacity-30" : "opacity-70"
|
||||
)}
|
||||
onClick={() => setExpand(!expand)}
|
||||
title="Docs Aside Links Dropdown Button"
|
||||
>
|
||||
<ChevronDown className="text-slate-500" size={20} />
|
||||
</Button>
|
||||
@ -98,19 +104,20 @@ export default function TWUIDocsLink({
|
||||
</Row>
|
||||
{docLink.children && expand && (
|
||||
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
|
||||
<Divider vertical className="h-auto" />
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"gap-2 w-full",
|
||||
"gap-2 w-full pl-3",
|
||||
childWrapperProps?.className
|
||||
)}
|
||||
{...childWrapperProps}
|
||||
>
|
||||
{docLink.children.map((link, index) => (
|
||||
<TWUIDocsLink
|
||||
docLink={link}
|
||||
key={index}
|
||||
className="text-sm"
|
||||
docLink={link}
|
||||
className="text-sm opacity-70"
|
||||
autoExpandAll={autoExpandAll}
|
||||
child
|
||||
/>
|
||||
))}
|
||||
</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 Container from "../../layout/Container";
|
||||
import Row from "../../layout/Row";
|
||||
import Divider from "../../layout/Divider";
|
||||
import TWUIDocsAside from "./TWUIDocsAside";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Paper from "../../elements/Paper";
|
||||
import TWUIDocsRightAside from "./TWUIDocsRightAside";
|
||||
|
||||
export type DocsLinkType = {
|
||||
title: string;
|
||||
href: string;
|
||||
strict?: boolean;
|
||||
children?: DocsLinkType[];
|
||||
editPage?: string;
|
||||
};
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
DocsLinks: DocsLinkType[];
|
||||
@ -22,12 +31,7 @@ type Props = PropsWithChildren & {
|
||||
HTMLElement
|
||||
>;
|
||||
autoExpandAll?: boolean;
|
||||
};
|
||||
|
||||
export type DocsLinkType = {
|
||||
title: string;
|
||||
href: string;
|
||||
children?: DocsLinkType[];
|
||||
editPageURL?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -43,6 +47,7 @@ export default function TWUIDocs({
|
||||
docsContentProps,
|
||||
leftAsideProps,
|
||||
autoExpandAll,
|
||||
editPageURL,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack
|
||||
@ -51,10 +56,11 @@ export default function TWUIDocs({
|
||||
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
|
||||
>
|
||||
<Container>
|
||||
<Paper className="xl:p-8 mobile-paper-hidden">
|
||||
<Row
|
||||
{...docsContentProps}
|
||||
className={twMerge(
|
||||
"items-stretch gap-6 w-full flex-nowrap",
|
||||
"items-start gap-8 w-full flex-nowrap",
|
||||
docsContentProps?.className
|
||||
)}
|
||||
>
|
||||
@ -65,11 +71,17 @@ export default function TWUIDocs({
|
||||
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
|
||||
className={twMerge(
|
||||
"block twui-docs-content pl-0 xl:pl-6 grow",
|
||||
"overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<TWUIDocsRightAside editPageURL={editPageURL} />
|
||||
</Row>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { MutableRefObject } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import AceEditorModes from "./ace-editor-modes";
|
||||
|
||||
export type AceEditorComponentType = {
|
||||
editorRef?: MutableRefObject<AceAjax.Editor>;
|
||||
@ -8,11 +9,16 @@ export type AceEditorComponentType = {
|
||||
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
|
||||
content?: string;
|
||||
placeholder?: string;
|
||||
mode?: any;
|
||||
mode?: (typeof AceEditorModes)[number];
|
||||
fontSize?: string;
|
||||
previewMode?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
delay?: number;
|
||||
wrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
@ -34,10 +40,13 @@ export default function AceEditor({
|
||||
previewMode,
|
||||
onChange,
|
||||
delay = 500,
|
||||
refresh: externalRefresh,
|
||||
wrapperProps,
|
||||
}: AceEditorComponentType) {
|
||||
try {
|
||||
const editorElementRef = React.useRef<HTMLDivElement>();
|
||||
const editorRefInstance = React.useRef<AceAjax.Editor>();
|
||||
const editorElementRef = React.useRef<HTMLDivElement>(null);
|
||||
// const editorRefInstance = React.useRef<AceAjax.Editor>(null);
|
||||
const editorRefInstance = React.useRef<any>(null);
|
||||
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [darkMode, setDarkMode] = React.useState(false);
|
||||
@ -58,7 +67,7 @@ export default function AceEditor({
|
||||
editor.setOptions({
|
||||
mode: `ace/mode/${mode ? mode : "javascript"}`,
|
||||
theme: darkMode
|
||||
? "ace/theme/tomorrow_night_bright"
|
||||
? "ace/theme/tomorrow_night_eighties"
|
||||
: "ace/theme/ace_light",
|
||||
value: content,
|
||||
placeholder: placeholder ? placeholder : "",
|
||||
@ -89,14 +98,17 @@ export default function AceEditor({
|
||||
|
||||
setTimeout(() => {
|
||||
onChange(editor.getValue());
|
||||
console.log(editor.getValue());
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
|
||||
editorRefInstance.current = editor;
|
||||
if (editorRef) editorRef.current = editor;
|
||||
}, [refresh, darkMode, ready]);
|
||||
|
||||
return function () {
|
||||
editor.destroy();
|
||||
};
|
||||
}, [refresh, darkMode, ready, externalRefresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlClassName = document.documentElement.className;
|
||||
@ -109,10 +121,12 @@ export default function AceEditor({
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
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",
|
||||
"dark:border-white/20"
|
||||
"dark:border-white/20",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||
import Border from "../../elements/Border";
|
||||
|
||||
export type TinyMCEEditorProps = {
|
||||
export type TinyMCEEditorProps<KeyType extends string> = {
|
||||
tinyMCE?: TinyMCE | null;
|
||||
options?: RawEditorOptions;
|
||||
editorRef?: React.MutableRefObject<Editor | null>;
|
||||
@ -11,7 +13,17 @@ export type TinyMCEEditorProps = {
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
wrapperWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
borderProps?: ComponentProps<typeof Border>;
|
||||
defaultValue?: string;
|
||||
name?: KeyType;
|
||||
changeHandler?: (content: string) => void;
|
||||
showLabel?: boolean;
|
||||
useParentCSS?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
let interval: any;
|
||||
@ -20,60 +32,158 @@ let interval: any;
|
||||
* # Tiny MCE Editor Component
|
||||
* @className_wrapper twui-rte-wrapper
|
||||
*/
|
||||
export default function TinyMCEEditor({
|
||||
export default function TinyMCEEditor<KeyType extends string>({
|
||||
options,
|
||||
editorRef,
|
||||
setEditor,
|
||||
tinyMCE,
|
||||
wrapperProps,
|
||||
defaultValue,
|
||||
}: TinyMCEEditorProps) {
|
||||
changeHandler,
|
||||
wrapperWrapperProps,
|
||||
borderProps,
|
||||
name,
|
||||
showLabel,
|
||||
useParentCSS,
|
||||
placeholder,
|
||||
}: TinyMCEEditorProps<KeyType>) {
|
||||
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
tinyMCE?.init({
|
||||
height: FINAL_HEIGHT,
|
||||
menubar: false,
|
||||
plugins: [
|
||||
"advlist lists link image charmap print preview anchor",
|
||||
"searchreplace visualblocks code fullscreen",
|
||||
"insertdatetime media table paste code help wordcount",
|
||||
],
|
||||
plugins:
|
||||
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
|
||||
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:
|
||||
"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) => {
|
||||
setEditor?.(editor as any);
|
||||
if (editorRef) editorRef.current = editor as any;
|
||||
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",
|
||||
body_class: "twui-tinymce",
|
||||
placeholder,
|
||||
relative_urls: true,
|
||||
remove_script_host: true,
|
||||
convert_urls: false,
|
||||
...options,
|
||||
license_key: "gpl",
|
||||
target: editorComponentRef.current,
|
||||
content_css: darkMode ? "dark" : undefined,
|
||||
skin: darkMode ? "oxide-dark" : undefined,
|
||||
});
|
||||
}, [tinyMCE]);
|
||||
|
||||
return function () {
|
||||
tinyMCE?.remove();
|
||||
};
|
||||
}, [tinyMCE, themeReady]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge(
|
||||
"relative w-full [&_.tox-tinymce]:!border-none",
|
||||
"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: FINAL_HEIGHT + "px",
|
||||
height:
|
||||
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
|
||||
...wrapperProps?.style,
|
||||
}}
|
||||
className={twMerge(
|
||||
"bg-slate-200 dark:bg-slate-700 rounded-sm",
|
||||
"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
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border border-solid rounded",
|
||||
"border-slate-300 dark:border-white/10",
|
||||
"relative flex items-center gap-2 border border-solid rounded-default",
|
||||
"border-slate-200 dark:border-white/10",
|
||||
spacing
|
||||
? spacing == "normal"
|
||||
? "px-3 py-2"
|
||||
|
@ -1,37 +1,203 @@
|
||||
import React from "react";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
import Link from "../layout/Link";
|
||||
import Divider from "../layout/Divider";
|
||||
import Row from "../layout/Row";
|
||||
import lowerToTitleCase from "../utils/lower-to-title-case";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import Button from "../layout/Button";
|
||||
|
||||
type LinkObject = {
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
excludeRegexMatch?: RegExp;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
currentLinkProps?: ComponentProps<typeof Link>;
|
||||
dividerProps?: ComponentProps<typeof Divider>;
|
||||
backButtonProps?: ComponentProps<typeof Button>;
|
||||
backButton?: boolean;
|
||||
pageUrl?: string;
|
||||
currentTitle?: string;
|
||||
skipHome?: boolean;
|
||||
divider?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* # TWUI Breadcrumbs
|
||||
* @className `twui-current-breadcrumb-link`
|
||||
* @className `twui-breadcrumb-link`
|
||||
* @className `twui-current-breadcrumb-wrapper`
|
||||
* @className `twui-breadcrumbs-divider`
|
||||
*/
|
||||
export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
const [links, setLinks] = React.useState<LinkObject[] | null>(null);
|
||||
const [current, setCurrent] = React.useState(false);
|
||||
export default function Breadcrumbs({
|
||||
excludeRegexMatch,
|
||||
linkProps,
|
||||
currentLinkProps,
|
||||
dividerProps,
|
||||
backButton,
|
||||
backButtonProps,
|
||||
pageUrl,
|
||||
currentTitle,
|
||||
skipHome,
|
||||
divider,
|
||||
}: Props) {
|
||||
const [links, setLinks] = React.useState<LinkObject[] | null>(
|
||||
pageUrl
|
||||
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
|
||||
: null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (links) return;
|
||||
|
||||
let pathname = window.location.pathname;
|
||||
let pathLinks = pathname.split("/");
|
||||
|
||||
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
|
||||
url: pathname,
|
||||
excludeRegexMatch,
|
||||
skipHome,
|
||||
});
|
||||
|
||||
setLinks(validPathLinks);
|
||||
|
||||
return function () {
|
||||
setLinks(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!links?.[1]) {
|
||||
return <React.Fragment></React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={twMerge(
|
||||
"overflow-x-auto",
|
||||
"twui-current-breadcrumb-wrapper"
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
|
||||
)}
|
||||
>
|
||||
{backButton && (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
{...backButtonProps}
|
||||
className={twMerge(
|
||||
"p-1 -my-2 -mx-2",
|
||||
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) => {
|
||||
const isTarget = array.length - 1 == index;
|
||||
|
||||
if (index === links.length - 1) {
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
|
||||
"max-w-[200px] text-ellipsis overflow-hidden",
|
||||
isTarget ? "current" : "",
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
title={
|
||||
currentLinkProps?.title || linkObject.title
|
||||
}
|
||||
>
|
||||
{currentTitle || linkObject.title}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={linkObject.path}
|
||||
{...linkProps}
|
||||
{...(isTarget ? currentLinkProps : {})}
|
||||
className={twMerge(
|
||||
"text-xs",
|
||||
isTarget ? "current" : "",
|
||||
"twui-breadcrumb-link",
|
||||
linkProps?.className,
|
||||
isTarget && currentLinkProps?.className
|
||||
)}
|
||||
>
|
||||
{currentLinkProps?.title ||
|
||||
linkObject.title}
|
||||
</Link>
|
||||
{divider || (
|
||||
<Divider
|
||||
vertical
|
||||
{...dividerProps}
|
||||
className={twMerge(
|
||||
"twui-breadcrumbs-divider",
|
||||
dividerProps?.className
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Row>
|
||||
</nav>
|
||||
);
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
}
|
||||
|
||||
export function twuiBreadcrumbsGenerateLinksFromUrl({
|
||||
url,
|
||||
excludeRegexMatch,
|
||||
skipHome,
|
||||
}: {
|
||||
url: string;
|
||||
excludeRegexMatch?: RegExp;
|
||||
skipHome?: boolean;
|
||||
}) {
|
||||
let pathLinks = url.split("/");
|
||||
|
||||
let validPathLinks = [];
|
||||
|
||||
if (!skipHome) {
|
||||
validPathLinks.push({
|
||||
title: "Home",
|
||||
path: pathname.match(/admin/) ? "/admin" : "/",
|
||||
path: url.match(/admin/) ? "/admin" : "/",
|
||||
});
|
||||
}
|
||||
|
||||
pathLinks.forEach((linkText, index, array) => {
|
||||
if (!linkText?.match(/./)) {
|
||||
@ -57,71 +223,5 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
|
||||
});
|
||||
});
|
||||
|
||||
setLinks(validPathLinks);
|
||||
|
||||
return function () {
|
||||
setLinks(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!links?.[1]) {
|
||||
return <React.Fragment></React.Fragment>;
|
||||
return validPathLinks;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-x-auto max-w-[70vw]",
|
||||
"twui-current-breadcrumb-wrapper"
|
||||
)}
|
||||
>
|
||||
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
|
||||
{links.map((linkObject, index, array) => {
|
||||
const isTarget = array.length - 1 == index;
|
||||
|
||||
if (index === links.length - 1) {
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={linkObject.path}
|
||||
className={twMerge(
|
||||
"text-slate-400 dark:text-slate-500 pointer-events-none text-xs",
|
||||
isTarget ? "current" : "",
|
||||
"twui-current-breadcrumb-link"
|
||||
)}
|
||||
>
|
||||
{linkObject.title}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={linkObject.path}
|
||||
className={twMerge(
|
||||
"text-xs",
|
||||
isTarget ? "current" : "",
|
||||
"twui-current-breadcrumb-link"
|
||||
)}
|
||||
>
|
||||
{linkObject.title}
|
||||
</Link>
|
||||
<Divider vertical />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
////////////////////////////////////////
|
||||
}
|
||||
|
||||
/** ****************************************************************************** */
|
||||
/** ****************************************************************************** */
|
||||
/** ****************************************************************************** */
|
||||
/** ****************************************************************************** */
|
||||
/** ****************************************************************************** */
|
||||
/** ****************************************************************************** */
|
||||
|
@ -1,5 +1,10 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Link from "../layout/Link";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
@ -7,11 +12,10 @@ type Props = DetailedHTMLProps<
|
||||
> & {
|
||||
variant?: "normal";
|
||||
href?: string;
|
||||
linkProps?: DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
linkProps?: ComponentProps<typeof Link>;
|
||||
noHover?: boolean;
|
||||
elRef?: React.RefObject<HTMLDivElement>;
|
||||
linkRef?: React.RefObject<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -27,20 +31,18 @@ export default function Card({
|
||||
variant,
|
||||
linkProps,
|
||||
noHover,
|
||||
elRef,
|
||||
linkRef,
|
||||
...props
|
||||
}: Props) {
|
||||
const component = (
|
||||
<div
|
||||
ref={elRef}
|
||||
{...props}
|
||||
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",
|
||||
noHover
|
||||
? ""
|
||||
: href
|
||||
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
|
||||
: "",
|
||||
"twui-card",
|
||||
noHover ? "" : "twui-card",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
@ -50,28 +52,19 @@ export default function Card({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
ref={linkRef}
|
||||
href={href}
|
||||
{...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(
|
||||
"cursor-pointer",
|
||||
"twui-card",
|
||||
"twui-card-link",
|
||||
linkProps?.className
|
||||
)}
|
||||
>
|
||||
{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,
|
||||
...props
|
||||
}: Props) {
|
||||
const codeRef = React.useRef<HTMLDivElement>();
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
@ -52,9 +52,9 @@ export default function CodeBlock({
|
||||
<div
|
||||
{...wrapperProps}
|
||||
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`,
|
||||
"relative",
|
||||
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
|
||||
"twui-code-block-wrapper",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
@ -62,7 +62,6 @@ export default function CodeBlock({
|
||||
boxShadow: copied
|
||||
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
|
||||
: undefined,
|
||||
maxWidth: "calc(100vw - 80px)",
|
||||
backgroundColor: finalBackgroundColor,
|
||||
...props.style,
|
||||
}}
|
||||
@ -99,7 +98,7 @@ export default function CodeBlock({
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
beforeIcon={<Copy size={17} color="white" />}
|
||||
className="!p-1 !bg-transparent"
|
||||
className="!p-1 !bg-transparent opacity-50"
|
||||
onClick={() => {
|
||||
const content =
|
||||
codeRef.current?.textContent;
|
||||
@ -136,7 +135,7 @@ export default function CodeBlock({
|
||||
<pre
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"!my-0",
|
||||
"!my-0 whitespace-pre-wrap",
|
||||
language ? `language-${language}` : "",
|
||||
"twui-code-block-pre",
|
||||
props.className
|
||||
|
@ -1,26 +1,71 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
active?: boolean;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
iconWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultScheme?: "light" | "dark";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Color Scheme Loader
|
||||
* @className_wrapper twui-color-scheme-selector
|
||||
*/
|
||||
export default function ColorSchemeSelector({
|
||||
active,
|
||||
setActive,
|
||||
toggleProps,
|
||||
active: initialActive,
|
||||
setActive: externalSetActive,
|
||||
iconWrapperProps,
|
||||
defaultScheme,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
toggleProps?: TWUI_TOGGLE_PROPS;
|
||||
active: boolean;
|
||||
setActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
}: Props) {
|
||||
const [active, setActive] = React.useState(initialActive);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isDocumentDark =
|
||||
document.documentElement.classList.contains("dark");
|
||||
const isDocumentLight =
|
||||
document.documentElement.classList.contains("light");
|
||||
|
||||
if (isDocumentDark) {
|
||||
setActive(true);
|
||||
return;
|
||||
} else if (isDocumentLight) {
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTheme = localStorage.getItem("theme");
|
||||
|
||||
if (existingTheme === "dark") {
|
||||
setActive(true);
|
||||
} else if (existingTheme === "light") {
|
||||
setActive(false);
|
||||
} else if (defaultScheme) {
|
||||
setActive(defaultScheme == "dark" ? false : true);
|
||||
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
setActive(true);
|
||||
} else if (typeof active == "undefined") {
|
||||
setActive(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof active == "undefined") return;
|
||||
|
||||
if (active) {
|
||||
document.documentElement.className = "dark";
|
||||
localStorage.setItem("theme", "dark");
|
||||
} else {
|
||||
document.documentElement.className = "";
|
||||
document.documentElement.className = "light";
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
@ -33,7 +78,24 @@ export default function ColorSchemeSelector({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -7,7 +7,11 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const TWUIDropdownContentPositions = [
|
||||
"left",
|
||||
"bottom-left",
|
||||
"top-left",
|
||||
"right",
|
||||
"bottom-right",
|
||||
"top-right",
|
||||
"center",
|
||||
] as const;
|
||||
|
||||
@ -23,15 +27,17 @@ export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
||||
HTMLDivElement
|
||||
>;
|
||||
debounce?: number;
|
||||
openDebounce?: number;
|
||||
hoverOpen?: boolean;
|
||||
above?: boolean;
|
||||
position?: (typeof TWUIDropdownContentPositions)[number];
|
||||
topOffset?: number;
|
||||
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
externalOpen?: boolean;
|
||||
keepOpen?: boolean;
|
||||
disableClickActions?: boolean;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-dropdown-wrapper
|
||||
@ -45,16 +51,24 @@ export default function Dropdown({
|
||||
targetWrapperProps,
|
||||
hoverOpen,
|
||||
above,
|
||||
debounce = 500,
|
||||
debounce = 200,
|
||||
openDebounce = 200,
|
||||
target,
|
||||
position = "center",
|
||||
topOffset,
|
||||
externalSetOpen,
|
||||
keepOpen,
|
||||
disableClickActions,
|
||||
externalOpen,
|
||||
...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 dropdownContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
@ -71,12 +85,17 @@ export default function Dropdown({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (keepOpen) return;
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpen(externalOpen);
|
||||
}, [externalOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
@ -88,11 +107,18 @@ export default function Dropdown({
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
openTimeout = setTimeout(() => {
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
}, openDebounce);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave={(e) => {
|
||||
if (!hoverOpen) return;
|
||||
|
||||
window.clearTimeout(openTimeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
externalSetOpen?.(false);
|
||||
setOpen(false);
|
||||
@ -107,6 +133,7 @@ export default function Dropdown({
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
if (targetEl?.closest?.(".cancel-link")) return;
|
||||
if (disableClickActions) return;
|
||||
externalSetOpen?.(!open);
|
||||
setOpen(!open);
|
||||
}}
|
||||
@ -122,12 +149,18 @@ export default function Dropdown({
|
||||
<div
|
||||
{...contentWrapperProps}
|
||||
className={twMerge(
|
||||
"absolute z-10",
|
||||
"absolute z-10 mt-1",
|
||||
position == "left"
|
||||
? "left-0"
|
||||
? "left-[100%] top-[50%] -translate-y-[50%]"
|
||||
: 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%]" : "",
|
||||
open ? "flex" : "hidden",
|
||||
"twui-dropdown-content",
|
||||
@ -142,9 +175,10 @@ export default function Dropdown({
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
style={{
|
||||
top: `calc(100% + ${topOffset || 0}px)`,
|
||||
// top: `calc(100% + ${topOffset || 0}px)`,
|
||||
...contentWrapperProps?.style,
|
||||
}}
|
||||
ref={dropdownContentRef}
|
||||
>
|
||||
{props.children}
|
||||
</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
|
||||
aria-hidden="true"
|
||||
className={twMerge(
|
||||
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600",
|
||||
"twui-loading",
|
||||
"text-gray animate-spin dark:text-gray-dark fill-primary",
|
||||
"dark:fill-white twui-loading",
|
||||
sizeClassName,
|
||||
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 ModalComponent from "../(partials)/ModalComponent";
|
||||
import PopoverComponent from "../(partials)/PopoverComponent";
|
||||
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>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
target: React.ReactNode;
|
||||
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>;
|
||||
target?: React.ReactNode;
|
||||
targetRef?: React.RefObject<HTMLDivElement>;
|
||||
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
|
||||
targetWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
open?: boolean;
|
||||
isPopover?: boolean;
|
||||
position?: (typeof TWUIPopoverStyles)[number];
|
||||
trigger?: (typeof TWUIPopoverTriggers)[number];
|
||||
debounce?: number;
|
||||
onClose?: () => any;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Modal Component
|
||||
* @className_wrapper twui-modal-root
|
||||
* @className_wrapper twui-modal
|
||||
* @ID twui-modal-root
|
||||
* @className twui-modal-content
|
||||
* @className twui-modal
|
||||
* @ID twui-popover-root
|
||||
* @className twui-popover-content
|
||||
* @className twui-popover-target
|
||||
*/
|
||||
export default function Modal({ target, targetRef, ...props }: Props) {
|
||||
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
|
||||
export default function Modal(props: TWUI_MODAL_PROPS) {
|
||||
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(() => {
|
||||
const wrapperEl = document.createElement("div");
|
||||
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
|
||||
const modalRoot = document.getElementById(IDName);
|
||||
|
||||
wrapperEl.className = twMerge(
|
||||
"fixed z-[200000] top-0 left-0 w-screen h-screen",
|
||||
"flex flex-col items-center justify-center",
|
||||
"twui-modal-root"
|
||||
);
|
||||
|
||||
setWrapper(wrapperEl);
|
||||
if (modalRoot) {
|
||||
setReady(true);
|
||||
} else {
|
||||
const newModalRootEl = document.createElement("div");
|
||||
newModalRootEl.id = IDName;
|
||||
document.body.appendChild(newModalRootEl);
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalEl = (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 bg-slate-900/80 z-0",
|
||||
"w-screen h-screen"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
closeModal({ wrapperEl: wrapper });
|
||||
}}
|
||||
></div>
|
||||
<Paper
|
||||
{...props}
|
||||
className={twMerge("z-10 max-w-[500px]", props.className)}
|
||||
>
|
||||
{props.children}
|
||||
</Paper>
|
||||
</React.Fragment>
|
||||
);
|
||||
React.useEffect(() => {
|
||||
existingSetOpen?.(open);
|
||||
if (open == false) onClose?.();
|
||||
}, [open]);
|
||||
|
||||
const targetEl = (
|
||||
React.useEffect(() => {
|
||||
setOpen(existingOpen || false);
|
||||
}, [existingOpen]);
|
||||
|
||||
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
|
||||
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
|
||||
|
||||
const popoverTargetActiveRef = React.useRef(false);
|
||||
const popoverContentActiveRef = React.useRef(false);
|
||||
|
||||
let closeTimeout: any;
|
||||
|
||||
const popoverEnterFn = React.useCallback((e: any) => {
|
||||
popoverTargetActiveRef.current = true;
|
||||
popoverContentActiveRef.current = false;
|
||||
setOpen(true);
|
||||
props.onMouseEnter?.(e);
|
||||
}, []);
|
||||
|
||||
const popoverLeaveFn = React.useCallback((e: any) => {
|
||||
window.clearTimeout(closeTimeout);
|
||||
closeTimeout = setTimeout(() => {
|
||||
// if (popoverTargetActiveRef.current) {
|
||||
// popoverTargetActiveRef.current = false;
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (popoverContentActiveRef.current) {
|
||||
popoverContentActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
props.onMouseLeave?.(e);
|
||||
}, []);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
const closestWrapper = targetEl.closest(".twui-popover-content");
|
||||
const closestTarget = targetEl.closest(".twui-popover-target");
|
||||
|
||||
if (closestTarget) return;
|
||||
|
||||
if (!closestWrapper) {
|
||||
return setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isPopover) return;
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{target ? (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
if (!wrapper) return;
|
||||
document.body.appendChild(wrapper);
|
||||
const root = createRoot(wrapper);
|
||||
root.render(modalEl);
|
||||
}}
|
||||
ref={targetRef as any}
|
||||
{...targetWrapperProps}
|
||||
onClick={(e) => setOpen(!open)}
|
||||
ref={finalTargetRef}
|
||||
onMouseEnter={
|
||||
isPopover && trigger === "hover"
|
||||
? popoverEnterFn
|
||||
: targetWrapperProps?.onMouseEnter
|
||||
}
|
||||
onMouseLeave={
|
||||
isPopover && trigger === "hover"
|
||||
? popoverLeaveFn
|
||||
: targetWrapperProps?.onMouseLeave
|
||||
}
|
||||
className={twMerge(
|
||||
"twui-popover-target",
|
||||
targetWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
) : null}
|
||||
{ready ? (
|
||||
isPopover ? (
|
||||
<PopoverComponent
|
||||
{...props}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
targetElRef={finalPopoverReferenceRef}
|
||||
debounce={debounce}
|
||||
popoverTargetActiveRef={popoverTargetActiveRef}
|
||||
popoverContentActiveRef={popoverContentActiveRef}
|
||||
/>
|
||||
) : (
|
||||
<ModalComponent {...props} open={open} setOpen={setOpen} />
|
||||
)
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
@ -8,6 +8,7 @@ import { twMerge } from "tailwind-merge";
|
||||
export default function Paper({
|
||||
variant,
|
||||
linkProps,
|
||||
componentRef,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
variant?: "normal";
|
||||
@ -15,13 +16,16 @@ export default function Paper({
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
componentRef?: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={componentRef as any}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
|
||||
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||
"relative",
|
||||
"twui-paper",
|
||||
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 Row from "../layout/Row";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
InputHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
|
||||
let timeout: any;
|
||||
|
||||
@ -16,12 +12,15 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
HTMLDivElement
|
||||
> & {
|
||||
dispatch?: (value?: string) => void;
|
||||
changeHandler?: (value?: string) => void;
|
||||
delay?: number;
|
||||
inputProps?: InputProps<KeyType>;
|
||||
buttonProps?: DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>;
|
||||
loading?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -32,9 +31,12 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
*/
|
||||
export default function Search<KeyType extends string>({
|
||||
dispatch,
|
||||
changeHandler,
|
||||
delay = 500,
|
||||
inputProps,
|
||||
buttonProps,
|
||||
loading,
|
||||
placeholder,
|
||||
...props
|
||||
}: SearchProps<KeyType>) {
|
||||
const [input, setInput] = React.useState("");
|
||||
@ -44,10 +46,11 @@ export default function Search<KeyType extends string>({
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
dispatch?.(input);
|
||||
changeHandler?.(input);
|
||||
}, delay);
|
||||
}, [input]);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.autoFocus) {
|
||||
@ -66,7 +69,7 @@ export default function Search<KeyType extends string>({
|
||||
>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
placeholder={placeholder || "Search"}
|
||||
{...inputProps}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@ -81,17 +84,21 @@ export default function Search<KeyType extends string>({
|
||||
componentRef={inputRef}
|
||||
/>
|
||||
<Button
|
||||
loadingProps={{ size: "small" }}
|
||||
{...buttonProps}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className={twMerge(
|
||||
"rounded-l-none my-[1px]",
|
||||
"rounded-l-none ml-[1px]",
|
||||
"twui-search-button",
|
||||
buttonProps?.className
|
||||
)}
|
||||
onClick={() => {
|
||||
dispatch?.(input);
|
||||
changeHandler?.(input);
|
||||
}}
|
||||
title="Search Button"
|
||||
loading={loading}
|
||||
>
|
||||
<SearchIcon
|
||||
className="text-slate-800 dark:text-white"
|
||||
|
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 Border from "./Border";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import Span from "../layout/Span";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
value?: string;
|
||||
content: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
tabsContentArray: TWUITabsObject[];
|
||||
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
|
||||
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
@ -21,6 +21,11 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
>;
|
||||
centered?: boolean;
|
||||
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,
|
||||
centered,
|
||||
debounce = 100,
|
||||
switchComponent,
|
||||
setActiveValue: existingSetActiveValue,
|
||||
...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(
|
||||
tabsContentArray.find((ctn) => ctn.defaultActive)?.value ||
|
||||
values[0] ||
|
||||
undefined
|
||||
defaultActiveObj
|
||||
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||
: values[0] || undefined
|
||||
);
|
||||
|
||||
const targetContent = tabsContentArray.find(
|
||||
(ctn) => ctn.value == activeValue
|
||||
const targetContent = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
existingSetActiveValue?.(activeValue);
|
||||
}, [activeValue]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
@ -63,16 +85,21 @@ export default function Tabs({
|
||||
tabsButtonsWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<Border className="p-0 w-full" {...tabsBorderProps}>
|
||||
<Border
|
||||
className="p-0 w-full overflow-hidden"
|
||||
{...tabsBorderProps}
|
||||
>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-0 items-stretch w-full",
|
||||
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
|
||||
centered && "justify-center"
|
||||
)}
|
||||
>
|
||||
{values.map((value, index) => {
|
||||
const targetObject = tabsContentArray.find(
|
||||
(ctn) => ctn.value == value
|
||||
const targetObject = finalTabsContentArray.find(
|
||||
(ctn) =>
|
||||
ctn.value == value ||
|
||||
twuiSlugify(ctn.title) == value
|
||||
);
|
||||
|
||||
const isActive = value == activeValue;
|
||||
@ -80,9 +107,9 @@ export default function Tabs({
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"px-6 py-2 rounded -ml-[1px]",
|
||||
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
|
||||
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" +
|
||||
" cursor-pointer",
|
||||
"twui-tab-buttons"
|
||||
@ -102,7 +129,7 @@ export default function Tabs({
|
||||
</Row>
|
||||
</Border>
|
||||
</div>
|
||||
{targetContent?.content}
|
||||
{activeValue ? targetContent?.content : switchComponent || null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@ -16,57 +16,68 @@ export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
||||
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
||||
variant?: "normal" | "outlined" | "ghost";
|
||||
href?: string;
|
||||
newTab?: boolean;
|
||||
linkProps?: React.DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tabs Component
|
||||
* @className twui-tag
|
||||
* @className twui-tag-primary-outlined
|
||||
*/
|
||||
export default function Tag({
|
||||
color,
|
||||
variant,
|
||||
children,
|
||||
href,
|
||||
newTab,
|
||||
linkProps,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const mainComponent = (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xs px-2 py-0.5 rounded-full outline outline-0",
|
||||
"text-xs px-2 py-0.5 rounded-full outline-0",
|
||||
"text-center flex items-center justify-center",
|
||||
color == "secondary"
|
||||
? "bg-violet-600 outline-violet-600"
|
||||
? "bg-secondary text-white outline-secbg-secondary"
|
||||
: color == "success"
|
||||
? "bg-emerald-700 outline-emerald-700"
|
||||
? "bg-success outline-success text-white"
|
||||
: color == "error"
|
||||
? "bg-orange-700 outline-orange-700"
|
||||
: color == "gray"
|
||||
? "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20 text-slate-500 dark:text-white"
|
||||
: "bg-blue-600 outline-blue-600",
|
||||
? twMerge(
|
||||
"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"
|
||||
? "!bg-transparent outline-1 " +
|
||||
(color == "secondary"
|
||||
? "text-violet-600"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: "text-primary dark:text-primary-dark twui-tag-primary-outlined")
|
||||
: variant == "ghost"
|
||||
? "!bg-transparent outline-none border-none " +
|
||||
(color == "secondary"
|
||||
? "text-violet-600"
|
||||
? "text-secondary"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
? "text-success dark:text-success-dark"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: "text-white",
|
||||
: "text-primary dark:text-primary-dark")
|
||||
: "",
|
||||
|
||||
"twui-tag",
|
||||
props.className
|
||||
@ -78,7 +89,12 @@ export default function Tag({
|
||||
|
||||
if (href) {
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Card from "./Card";
|
||||
import { X } from "lucide-react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||
@ -35,17 +35,47 @@ export default function Toast({
|
||||
color,
|
||||
...props
|
||||
}: TWUIToastProps) {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const IDName = "twui-toast-root";
|
||||
|
||||
React.useEffect(() => {
|
||||
const toastRoot = document.getElementById(IDName);
|
||||
|
||||
if (toastRoot) {
|
||||
setReady(true);
|
||||
} else {
|
||||
const newToastRootEl = document.createElement("div");
|
||||
newToastRootEl.id = IDName;
|
||||
document.body.appendChild(newToastRootEl);
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready || !open) return;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setOpen?.(false);
|
||||
}, closeDelay);
|
||||
|
||||
return function () {
|
||||
setOpen?.(false);
|
||||
};
|
||||
}, [ready, open]);
|
||||
|
||||
if (!ready) return null;
|
||||
if (!open) return null;
|
||||
|
||||
const toastEl = (
|
||||
return ReactDOM.createPortal(
|
||||
<Card
|
||||
{...props}
|
||||
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"
|
||||
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success"
|
||||
? "bg-success dark:bg-success-dark twui-toast-success"
|
||||
: color == "error"
|
||||
? "bg-orange-600 dark:bg-orange-700 twui-toast-error"
|
||||
? "bg-error dark:bg-error-dark twui-toast-error"
|
||||
: "",
|
||||
props.className,
|
||||
"twui-toast"
|
||||
@ -54,13 +84,7 @@ export default function Toast({
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const rootWrapperEl = targetEl.closest(
|
||||
".twui-toast-root"
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
closeToast({ wrapperEl: rootWrapperEl });
|
||||
setOpen?.(false);
|
||||
}, closeDelay);
|
||||
}}
|
||||
@ -71,48 +95,13 @@ export default function Toast({
|
||||
"text-white"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const rootWrapperEl = targetEl.closest(".twui-toast-root");
|
||||
|
||||
if (rootWrapperEl) {
|
||||
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
|
||||
setOpen?.(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</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
|
||||
> & {
|
||||
active?: boolean;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
|
||||
circleProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
@ -36,6 +36,9 @@ export default function Toggle({
|
||||
)}
|
||||
onClick={() => setActive?.(!active)}
|
||||
>
|
||||
{typeof active == "undefined" ? (
|
||||
<div className="w-3.5 h-3.5 twui-toggle-circle"></div>
|
||||
) : (
|
||||
<div
|
||||
{...circleProps}
|
||||
className={twMerge(
|
||||
@ -47,6 +50,7 @@ export default function Toggle({
|
||||
circleProps?.className
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,29 +1,38 @@
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import CheckMarkSVG from "../svgs/CheckMarkSVG";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import { Info } from "lucide-react";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type CheckboxProps = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
export type CheckboxProps = React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
name: string;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
labelProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultChecked?: boolean;
|
||||
wrapperClassName?: string;
|
||||
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,
|
||||
labelProps,
|
||||
size,
|
||||
name,
|
||||
wrapperClassName,
|
||||
defaultChecked,
|
||||
setChecked,
|
||||
setChecked: externalSetChecked,
|
||||
readOnly,
|
||||
checked: externalChecked,
|
||||
changeHandler,
|
||||
info,
|
||||
wrapperWrapperProps,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
|
||||
const [internalChecked, setInternalChecked] = React.useState(
|
||||
defaultChecked || false
|
||||
const [checked, setChecked] = React.useState(
|
||||
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 (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
onClick={(e) => {
|
||||
checkMarkRef.current?.click();
|
||||
wrapperProps?.onClick?.(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2",
|
||||
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
|
||||
readOnly ? "opacity-70 pointer-events-none" : "",
|
||||
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);
|
||||
onClick={() => {
|
||||
setChecked(!checked);
|
||||
externalSetChecked?.(!checked);
|
||||
}}
|
||||
ref={checkMarkRef as any}
|
||||
/>
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex items-center justify-center p-[3px] rounded",
|
||||
internalChecked
|
||||
? "bg-emerald-700 twui-checkbox-checked"
|
||||
: "outline-slate-600 dark:outline-white/50 outline-2 outline -outline-offset-2 twui-checkbox-unchecked",
|
||||
"twui-checkbox"
|
||||
"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,
|
||||
}}
|
||||
>
|
||||
{internalChecked && <CheckMarkSVG />}
|
||||
{checked && <CheckMarkSVG />}
|
||||
</div>
|
||||
{label && <label>{label}</label>}
|
||||
<Stack className="gap-0.5">
|
||||
<div
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"select-none whitespace-normal md:whitespace-nowrap",
|
||||
labelProps?.className
|
||||
)}
|
||||
>
|
||||
{label || finalTitle}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
{info && (
|
||||
<Row className="gap-1" 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 Stack from "../layout/Stack";
|
||||
import {
|
||||
File,
|
||||
FileArchive,
|
||||
FilePlus,
|
||||
FilePlus2,
|
||||
ImagePlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
import { FileArchive, FilePlus2, X } from "lucide-react";
|
||||
import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import imageInputToBase64, {
|
||||
FileInputToBase64FunctionReturn,
|
||||
} from "../utils/form/fileInputToBase64";
|
||||
import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import fileInputToBase64 from "../utils/form/fileInputToBase64";
|
||||
import Row from "../layout/Row";
|
||||
import Input from "./Input";
|
||||
import Loading from "../elements/Loading";
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChangeHandler?: (
|
||||
imgData: FileInputToBase64FunctionReturn | undefined
|
||||
fileData: FileInputToBase64FunctionReturn | undefined
|
||||
) => any;
|
||||
onClear?: () => void;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
@ -42,12 +36,21 @@ type ImageUploadProps = DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
label?: string;
|
||||
label?: string | ReactNode;
|
||||
disablePreview?: boolean;
|
||||
allowedRegex?: RegExp;
|
||||
externalSetFile?: React.Dispatch<
|
||||
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
|
||||
>;
|
||||
externalSetFiles?: React.Dispatch<
|
||||
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
|
||||
>;
|
||||
existingFile?: FileInputToBase64FunctionReturn;
|
||||
existingFileUrl?: string;
|
||||
icon?: ReactNode;
|
||||
labelSpanProps?: ComponentProps<typeof Span>;
|
||||
loading?: boolean;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -63,12 +66,37 @@ export default function FileUpload({
|
||||
disablePreview,
|
||||
allowedRegex,
|
||||
externalSetFile,
|
||||
externalSetFiles,
|
||||
existingFile,
|
||||
existingFileUrl,
|
||||
icon,
|
||||
labelSpanProps,
|
||||
loading,
|
||||
multiple,
|
||||
onClear,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [file, setFile] = React.useState<
|
||||
FileInputToBase64FunctionReturn | undefined
|
||||
>(undefined);
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
>(existingFile);
|
||||
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 (
|
||||
<Stack
|
||||
@ -77,9 +105,29 @@ export default function FileUpload({
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
className={twMerge("hidden", fileInputProps?.className)}
|
||||
{...fileInputProps}
|
||||
onChange={(e) => {
|
||||
if (multiple) {
|
||||
(async () => {
|
||||
const files = e.target.files;
|
||||
if (!files?.[0]) return;
|
||||
|
||||
let filesArr: FileInputToBase64FunctionReturn[] =
|
||||
[];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fileObj = await fileInputToBase64({
|
||||
inputFile: file,
|
||||
});
|
||||
filesArr.push(fileObj);
|
||||
}
|
||||
|
||||
externalSetFiles?.(filesArr);
|
||||
})();
|
||||
} else {
|
||||
const inputFile = e.target.files?.[0];
|
||||
|
||||
if (!inputFile) return;
|
||||
@ -92,15 +140,27 @@ export default function FileUpload({
|
||||
fileInputProps?.onChange?.(e);
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
ref={inputRef as any}
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
{loading ? (
|
||||
<Card className={twMerge("w-full h-full ")}>
|
||||
<Center>
|
||||
<Loading />
|
||||
</Center>
|
||||
</Card>
|
||||
) : file ? (
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center overflow-hidden"
|
||||
{...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!
|
||||
@ -112,15 +172,73 @@ export default function FileUpload({
|
||||
{...previewImageProps}
|
||||
/>
|
||||
) : (
|
||||
<Row>
|
||||
<Stack>
|
||||
<FileArchive size={36} strokeWidth={1} />
|
||||
<Stack className="gap-0">
|
||||
<Span>{file.file?.name || file.fileName}</Span>
|
||||
<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
|
||||
className="w-full relative h-full items-center justify-center overflow-hidden"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
||||
<Row>
|
||||
<FileArchive size={36} strokeWidth={1} />
|
||||
<Stack className="gap-0">
|
||||
<Span size="smaller" className="opacity-70">
|
||||
{fileUrl}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
) : (
|
||||
<img
|
||||
src={fileUrl}
|
||||
className="w-full object-contain overflow-hidden"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -132,7 +250,9 @@ export default function FileUpload({
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
setFileUrl(undefined);
|
||||
}}
|
||||
title="Cancel File Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
@ -140,19 +260,63 @@ export default function FileUpload({
|
||||
) : (
|
||||
<Card
|
||||
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
|
||||
)}
|
||||
onClick={(e) => {
|
||||
inputRef.current?.click();
|
||||
placeHolderWrapper?.onClick?.(e);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setFileDraggedOver(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
setFileDraggedOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setFileDraggedOver(false);
|
||||
let inputFile: File | null = null;
|
||||
|
||||
if (e.dataTransfer.items) {
|
||||
[...e.dataTransfer.items].forEach((item, i) => {
|
||||
if (inputFile) return;
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
inputFile = file;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
inputFile = e.dataTransfer.files?.[0];
|
||||
}
|
||||
|
||||
if (!inputFile) return;
|
||||
|
||||
fileInputToBase64({ inputFile, allowedRegex }).then(
|
||||
(res) => {
|
||||
setFile(res);
|
||||
externalSetFile?.(res);
|
||||
onChangeHandler?.(res);
|
||||
}
|
||||
);
|
||||
}}
|
||||
{...placeHolderWrapper}
|
||||
>
|
||||
<Center>
|
||||
<Center
|
||||
className={twMerge(
|
||||
fileDraggedOver ? "pointer-events-none" : ""
|
||||
)}
|
||||
>
|
||||
<Stack className="items-center gap-2">
|
||||
<FilePlus2 className="text-slate-400" />
|
||||
<Span size="smaller" variant="faded">
|
||||
{icon || <FilePlus2 className="text-slate-400" />}
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
{...labelSpanProps}
|
||||
>
|
||||
{label || "Click to Upload File"}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
@ -2,16 +2,20 @@ import _ from "lodash";
|
||||
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
|
||||
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Form Element
|
||||
* @className twui-form
|
||||
*/
|
||||
export default function Form<T extends object = { [key: string]: any }>({
|
||||
...props
|
||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
}) {
|
||||
const finalProps = _.omit(props, "submitHandler");
|
||||
export default function Form<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ ...props }: Props<T>) {
|
||||
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
|
||||
|
||||
return (
|
||||
<form
|
||||
@ -29,6 +33,15 @@ export default function Form<T extends object = { [key: string]: any }>({
|
||||
props.submitHandler?.(e, data);
|
||||
props.onSubmit?.(e);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
e.preventDefault();
|
||||
const taregtEl = e.target as HTMLElement;
|
||||
const formEl = taregtEl.closest("form") as HTMLFormElement;
|
||||
const formData = new FormData(formEl);
|
||||
const data = Object.fromEntries(formData.entries()) as T;
|
||||
props.changeHandler?.(e, data);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
|
@ -9,6 +9,7 @@ import imageInputToBase64, {
|
||||
ImageInputToBase64FunctionReturn,
|
||||
} from "../utils/form/imageInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "../elements/Tag";
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
@ -35,6 +36,17 @@ type ImageUploadProps = DetailedHTMLProps<
|
||||
>;
|
||||
label?: string;
|
||||
disablePreview?: boolean;
|
||||
multiple?: boolean;
|
||||
existingImageUrl?: string;
|
||||
externalSetImage?: React.Dispatch<
|
||||
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
|
||||
>;
|
||||
externalSetImages?: React.Dispatch<
|
||||
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
|
||||
>;
|
||||
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
externalImage?: ImageInputToBase64FunctionReturn;
|
||||
restoreImageFn?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -48,53 +60,118 @@ export default function ImageUpload({
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
existingImageUrl,
|
||||
externalSetImage,
|
||||
externalSetImages,
|
||||
externalImage,
|
||||
multiple,
|
||||
restoreImageFn,
|
||||
setLoading,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [src, setSrc] = React.useState<string | undefined>(undefined);
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
const [imageObject, setImageObject] = React.useState<
|
||||
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 (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full h-[300px]", props?.className)}
|
||||
className={twMerge(
|
||||
"w-full h-[300px] overflow-hidden",
|
||||
props?.className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
className={twMerge("hidden", fileInputProps?.className)}
|
||||
multiple={multiple}
|
||||
accept="image/*"
|
||||
{...fileInputProps}
|
||||
onChange={(e) => {
|
||||
imageInputToBase64({ imageInput: e.target }).then((res) => {
|
||||
setLoading?.(true);
|
||||
|
||||
if (multiple) {
|
||||
(async () => {
|
||||
const files = e.target.files;
|
||||
if (!files?.[0]) return;
|
||||
|
||||
let imgArr: ImageInputToBase64FunctionReturn[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fileObj = await imageInputToBase64({
|
||||
file,
|
||||
});
|
||||
imgArr.push(fileObj);
|
||||
}
|
||||
|
||||
externalSetImages?.(imgArr);
|
||||
setLoading?.(false);
|
||||
})();
|
||||
} else {
|
||||
imageInputToBase64({ imageInput: e.target }).then(
|
||||
(res) => {
|
||||
setSrc(res.imageBase64Full);
|
||||
onChangeHandler?.(res);
|
||||
setImageObject?.(res);
|
||||
externalSetImage?.(res);
|
||||
fileInputProps?.onChange?.(e);
|
||||
});
|
||||
setLoading?.(false);
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
ref={inputRef as any}
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
{src || imageObject?.imageBase64Full ? (
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
className={twMerge(
|
||||
"absolute top-0 left-0 text-xs z-50"
|
||||
)}
|
||||
>
|
||||
<Tag color="gray">
|
||||
<span className="opacity-70">{label}</span>
|
||||
</Tag>
|
||||
</label>
|
||||
)}
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
className="w-full object-contain"
|
||||
src={imageObject?.imageBase64Full || src}
|
||||
className="w-full h-full object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
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) => {
|
||||
setSrc(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
setImageObject?.(undefined);
|
||||
externalSetImage?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value == "";
|
||||
}
|
||||
}}
|
||||
title="Cancel Image Upload Button"
|
||||
>
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
@ -106,6 +183,11 @@ export default function ImageUpload({
|
||||
placeHolderWrapper?.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement | undefined;
|
||||
if (targetEl?.closest(".cancel-upload")) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
placeHolderWrapper?.onClick?.(e);
|
||||
}}
|
||||
@ -117,6 +199,20 @@ export default function ImageUpload({
|
||||
<Span size="smaller" variant="faded">
|
||||
{label || "Click to Upload Image"}
|
||||
</Span>
|
||||
{existingImageUrl && (
|
||||
<Button
|
||||
title="Restore Image Button"
|
||||
size="smaller"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
restoreImageFn?.() ||
|
||||
setSrc(existingImageUrl);
|
||||
}}
|
||||
className="cancel-upload"
|
||||
>
|
||||
Restore Original Image
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</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 {
|
||||
import { ChevronDown, Info, LucideProps } from "lucide-react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
ForwardRefExoticComponent,
|
||||
Dispatch,
|
||||
InputHTMLAttributes,
|
||||
LabelHTMLAttributes,
|
||||
RefAttributes,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SelectHTMLAttributes,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "../layout/Row";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Stack from "../layout/Stack";
|
||||
import twuiSlugify from "../utils/slugify";
|
||||
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
|
||||
|
||||
type SelectOptionObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
default?: boolean;
|
||||
export type TWUISelectValidityObject = {
|
||||
isValid?: boolean;
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
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>,
|
||||
HTMLSelectElement
|
||||
> & {
|
||||
options: SelectOptionObject[];
|
||||
options: TWUISelectOptionObject<KeyType, T>[];
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
wrapperWrapperProps?: ComponentProps<typeof Stack>;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
LabelHTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
componentRef?: RefObject<HTMLSelectElement>;
|
||||
iconProps?: LucideProps;
|
||||
changeHandler?: (value: 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-dropdown-icon
|
||||
*/
|
||||
export default function Select({
|
||||
export default function Select<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
>({
|
||||
label,
|
||||
options,
|
||||
componentRef,
|
||||
@ -51,63 +88,126 @@ export default function Select({
|
||||
showLabel,
|
||||
iconProps,
|
||||
changeHandler,
|
||||
info,
|
||||
validateValueFn,
|
||||
wrapperWrapperProps,
|
||||
dispatchState,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
}: TWUISelectProps<KeyType, T>) {
|
||||
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
|
||||
|
||||
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
|
||||
{
|
||||
value: options[0]?.value,
|
||||
data: options[0]?.data,
|
||||
}
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const currentSelectValue = selectRef.current?.value;
|
||||
|
||||
if (currentSelectValue && validateValueFn) {
|
||||
validateValueFn(currentSelectValue).then((res) => {
|
||||
setValidity(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatchState?.(value.data);
|
||||
}, [value]);
|
||||
|
||||
const selectID = label
|
||||
? twuiSlugify(label)
|
||||
: props.name
|
||||
? twuiSlugify(props.name)
|
||||
: props.title
|
||||
? twuiSlugify(props.title)
|
||||
: `select-${Math.round(Math.random() * 1000000)}`;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...wrapperWrapperProps}
|
||||
className={twMerge("gap-1", wrapperWrapperProps?.className)}
|
||||
>
|
||||
<div
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"relative w-full flex items-center",
|
||||
"relative w-full flex items-center border rounded-default",
|
||||
"border-slate-300 dark:border-white/20 pr-2",
|
||||
"focus:border-slate-700 dark:focus:border-white/50",
|
||||
"outline-slate-300 dark:outline-white/20",
|
||||
"focus:outline-slate-700 dark:focus:outline-white/50",
|
||||
"bg-white dark:bg-background-dark",
|
||||
validity.isValid ? "" : "outline-warning border-warning",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{showLabel && (
|
||||
<label
|
||||
htmlFor={props.name}
|
||||
htmlFor={selectID}
|
||||
{...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",
|
||||
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
|
||||
"dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
|
||||
"twui-input-label",
|
||||
labelProps?.className
|
||||
)}
|
||||
>
|
||||
{label || props.name}
|
||||
{label || props.title || props.name}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<select
|
||||
id={selectID}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full pl-3 py-2 border rounded-md appearance-none pr-8",
|
||||
"border-slate-300 dark:border-white/20",
|
||||
"focus:border-slate-700 dark:focus:border-white/50",
|
||||
"outline-slate-300 dark:outline-white/20",
|
||||
"focus:outline-slate-700 dark:focus:outline-white/50",
|
||||
"bg-white dark:bg-black",
|
||||
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
|
||||
"grow !border-none !outline-none",
|
||||
"twui-select",
|
||||
props.className
|
||||
)}
|
||||
ref={componentRef}
|
||||
ref={selectRef}
|
||||
value={
|
||||
options.flat().find((opt) => opt.default)?.value ||
|
||||
undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
changeHandler?.(
|
||||
e.target.value as (typeof options)[number]["value"]
|
||||
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}
|
||||
// selected={option.default || undefined}
|
||||
>
|
||||
{option.title}
|
||||
<option key={index} value={option.value}>
|
||||
{optionTitle}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@ -117,10 +217,35 @@ export default function Select({
|
||||
size={20}
|
||||
{...iconProps}
|
||||
className={twMerge(
|
||||
"absolute right-2 pointer-events-none",
|
||||
"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";
|
||||
|
||||
type Param = {
|
||||
elementRef?: React.MutableRefObject<Element | undefined>;
|
||||
elementRef?: React.RefObject<Element | undefined>;
|
||||
className?: string;
|
||||
elId?: string;
|
||||
options?: IntersectionObserverInit;
|
||||
removeIntersected?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
export default function useIntersectionObserver({
|
||||
elementRef,
|
||||
className,
|
||||
options,
|
||||
removeIntersected,
|
||||
delay,
|
||||
elId,
|
||||
}: Param) {
|
||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
|
||||
const observerTriggerDelay = delay || 200;
|
||||
|
||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||
(entries, observer) => {
|
||||
const entry = entries[0];
|
||||
window.clearTimeout(timeout);
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
timeout = setTimeout(() => {
|
||||
setIsIntersecting(true);
|
||||
|
||||
if (removeIntersected) {
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}, observerTriggerDelay);
|
||||
} else {
|
||||
setIsIntersecting(false);
|
||||
}
|
||||
@ -34,7 +45,9 @@ export default function useIntersectionObserver({
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = elementRef?.current;
|
||||
const element = elId
|
||||
? document.getElementById(elId)
|
||||
: elementRef?.current;
|
||||
const elements = className
|
||||
? document.querySelectorAll(`.${className}`)
|
||||
: 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;
|
||||
url: string;
|
||||
disableReconnect?: boolean;
|
||||
keepAliveDuration?: number;
|
||||
refreshConnection?: number;
|
||||
};
|
||||
|
||||
let reconnectInterval: any;
|
||||
let msgInterval: any;
|
||||
let sendInterval: any;
|
||||
|
||||
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
||||
|
||||
let tries = 0;
|
||||
@ -28,8 +26,25 @@ let tries = 0;
|
||||
*/
|
||||
export default function useWebSocket<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ url, debounce, disableReconnect }: UseWebsocketHookParams) {
|
||||
>({
|
||||
url,
|
||||
debounce,
|
||||
disableReconnect,
|
||||
keepAliveDuration,
|
||||
refreshConnection,
|
||||
}: UseWebsocketHookParams) {
|
||||
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>(
|
||||
undefined
|
||||
@ -38,6 +53,9 @@ export default function useWebSocket<
|
||||
const messageQueueRef = React.useRef<string[]>([]);
|
||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* # Dispatch Custom Event
|
||||
*/
|
||||
const dispatchCustomEvent = React.useCallback(
|
||||
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
||||
const event = new CustomEvent(evtName, {
|
||||
@ -51,6 +69,9 @@ export default function useWebSocket<
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* # Connect to Websocket
|
||||
*/
|
||||
const connect = React.useCallback(() => {
|
||||
const wsURL = url;
|
||||
if (!wsURL) return;
|
||||
@ -59,22 +80,41 @@ export default function useWebSocket<
|
||||
|
||||
ws.onopen = (ev) => {
|
||||
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);
|
||||
tries = 0;
|
||||
console.log(`Websocket connected to ${wsURL}`);
|
||||
uptime = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
window.clearInterval(msgInterval);
|
||||
messageQueueRef.current.push(ev.data);
|
||||
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||
if (ev.data !== KEEP_ALIVE_MESSAGE) {
|
||||
uptime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
console.log("Websocket closed!");
|
||||
|
||||
if (disableReconnect) return;
|
||||
|
||||
console.log("Websocket closed ... Attempting to reconnect ...");
|
||||
console.log("Attempting to reconnect ...");
|
||||
console.log("URL:", url);
|
||||
window.clearInterval(keepAliveInterval);
|
||||
|
||||
reconnectInterval = setInterval(() => {
|
||||
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(() => {
|
||||
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
|
||||
*/
|
||||
@ -113,6 +208,7 @@ export default function useWebSocket<
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
ComponentProps,
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributeAnchorTarget,
|
||||
HTMLAttributes,
|
||||
@ -12,10 +13,13 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
title: string;
|
||||
variant?: "normal" | "ghost" | "outlined";
|
||||
color?:
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "text"
|
||||
| "white"
|
||||
| "accent"
|
||||
| "gray"
|
||||
| "error"
|
||||
@ -36,6 +40,7 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
loadingProps?: ComponentProps<typeof Loading>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -48,12 +53,48 @@ export type TWUIButtonProps = DetailedHTMLProps<
|
||||
* @className twui-button-secondary
|
||||
* @className twui-button-secondary-outlined
|
||||
* @className twui-button-secondary-ghost
|
||||
* @className twui-button-white
|
||||
* @className twui-button-white-outlined
|
||||
* @className twui-button-white-ghost
|
||||
* @className twui-button-accent
|
||||
* @className twui-button-accent-outlined
|
||||
* @className twui-button-accent-ghost
|
||||
* @className twui-button-gray
|
||||
* @className twui-button-gray-outlined
|
||||
* @className twui-button-gray-ghost
|
||||
*
|
||||
* @example
|
||||
```css
|
||||
CSS directive:
|
||||
|
||||
//@theme inline {
|
||||
--breakpoint-xs: 350px;
|
||||
--color-primary: #000000;
|
||||
--color-primary-hover: #29292b;
|
||||
--color-primary-outline: #29292b;
|
||||
--color-primary-text: #29292b;
|
||||
--color-primary-dark: #29292b;
|
||||
--color-primary-dark-hover: #4b4b4b;
|
||||
--color-primary-dark-outline: #4b4b4b;
|
||||
--color-primary-dark-text: #4b4b4b;
|
||||
--color-secondary: #000000;
|
||||
--color-secondary-hover: #dddddd;
|
||||
--color-secondary-outline: #dddddd;
|
||||
--color-secondary-text: #dddddd;
|
||||
--color-secondary-dark: #000000;
|
||||
--color-secondary-dark-hover: #dddddd;
|
||||
--color-secondary-dark-outline: #dddddd;
|
||||
--color-secondary-dark-text: #dddddd;
|
||||
--color-accent: #000000;
|
||||
--color-accent-hover: #dddddd;
|
||||
--color-accent-outline: #dddddd;
|
||||
--color-accent-text: #dddddd;
|
||||
--color-accent-dark: #000000;
|
||||
--color-accent-dark-hover: #dddddd;
|
||||
--color-accent-dark-outline: #dddddd;
|
||||
--color-accent-dark-text: #dddddd;
|
||||
}
|
||||
```
|
||||
*/
|
||||
export default function Button({
|
||||
href,
|
||||
@ -67,47 +108,67 @@ export default function Button({
|
||||
afterIcon,
|
||||
loading,
|
||||
loadingIconSize,
|
||||
loadingProps,
|
||||
...props
|
||||
}: TWUIButtonProps) {
|
||||
const finalClassName: string = (() => {
|
||||
if (variant == "normal" || !variant) {
|
||||
if (color == "primary" || !color)
|
||||
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"
|
||||
);
|
||||
if (color == "secondary")
|
||||
return twMerge(
|
||||
"bg-emerald-500 hover:bg-emerald-600 text-white",
|
||||
"bg-secondary hover:bg-secondary-hover text-white",
|
||||
"twui-button-secondary"
|
||||
);
|
||||
if (color == "white")
|
||||
return twMerge(
|
||||
"!bg-white hover:!bg-slate-200 !text-slate-800",
|
||||
"twui-button-white"
|
||||
);
|
||||
if (color == "accent")
|
||||
return twMerge(
|
||||
"bg-violet-500 hover:bg-violet-600 text-white",
|
||||
"bg-accent hover:bg-accent-hover text-white",
|
||||
"twui-button-accent"
|
||||
);
|
||||
if (color == "gray")
|
||||
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"
|
||||
);
|
||||
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") {
|
||||
if (color == "primary" || !color)
|
||||
return twMerge(
|
||||
"bg-transparent outline outline-1 outline-blue-500",
|
||||
"text-blue-500 dark:text-blue-400 dark:outline-blue-300",
|
||||
"bg-transparent outline outline-1 outline-primary",
|
||||
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
|
||||
"twui-button-primary-outlined"
|
||||
);
|
||||
if (color == "secondary")
|
||||
return twMerge(
|
||||
"bg-transparent outline outline-1 outline-emerald-500",
|
||||
"text-emerald-500",
|
||||
"bg-transparent outline outline-1 outline-secondary",
|
||||
"text-secondary",
|
||||
"twui-button-secondary-outlined"
|
||||
);
|
||||
if (color == "accent")
|
||||
return twMerge(
|
||||
"bg-transparent outline outline-1 outline-violet-500",
|
||||
"text-violet-500",
|
||||
"bg-transparent outline outline-1 outline-accent",
|
||||
"text-accent",
|
||||
"twui-button-accent-outlined"
|
||||
);
|
||||
if (color == "gray")
|
||||
@ -116,23 +177,41 @@ export default function Button({
|
||||
"text-slate-600 dark:text-white/60 dark:outline-white/30",
|
||||
"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") {
|
||||
if (color == "primary" || !color)
|
||||
return twMerge(
|
||||
"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"
|
||||
);
|
||||
if (color == "secondary")
|
||||
return twMerge(
|
||||
"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"
|
||||
);
|
||||
if (color == "accent")
|
||||
return twMerge(
|
||||
"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"
|
||||
);
|
||||
if (color == "gray")
|
||||
@ -156,9 +235,15 @@ export default function Button({
|
||||
if (color == "success")
|
||||
return twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-emerald-600",
|
||||
"text-success",
|
||||
"twui-button-success-ghost"
|
||||
);
|
||||
if (color == "white")
|
||||
return twMerge(
|
||||
"bg-transparent outline-none p-2",
|
||||
"text-white",
|
||||
"twui-button-white-ghost"
|
||||
);
|
||||
}
|
||||
|
||||
return "";
|
||||
@ -168,17 +253,24 @@ export default function Button({
|
||||
<button
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"bg-blue-600 text-white text-base font-medium px-4 py-2 rounded",
|
||||
"flex items-center justify-center relative transition-all",
|
||||
"bg-primary text-white font-medium px-4 py-2 rounded-default",
|
||||
"flex items-center justify-center relative transition-all cursor-pointer",
|
||||
props.disabled ? "opacity-40 cursor-not-allowed" : "",
|
||||
"twui-button-general",
|
||||
size == "small" && "px-3 py-1.5 text-sm",
|
||||
size == "smaller" && "px-2 py-1 text-xs",
|
||||
size == "large" && "text-lg",
|
||||
size == "larger" && "px-5 py-3 text-xl",
|
||||
size == "small"
|
||||
? "px-3 py-1.5 text-sm twui-button-small"
|
||||
: size == "smaller"
|
||||
? "px-2 py-1 text-xs twui-button-smaller"
|
||||
: size == "large"
|
||||
? "text-lg twui-button-large"
|
||||
: size == "larger"
|
||||
? "px-5 py-3 text-xl twui-button-larger"
|
||||
: "twui-button-base",
|
||||
finalClassName,
|
||||
props.className,
|
||||
loading ? "pointer-events-none opacity-80" : "l"
|
||||
loading ? "pointer-events-none opacity-80" : "",
|
||||
props.className
|
||||
)}
|
||||
aria-label={props.title}
|
||||
>
|
||||
<div
|
||||
{...buttonContentProps}
|
||||
@ -196,7 +288,6 @@ export default function Button({
|
||||
|
||||
{loading && (
|
||||
<Loading
|
||||
className="absolute"
|
||||
size={(() => {
|
||||
if (loadingIconSize) return loadingIconSize;
|
||||
switch (size) {
|
||||
@ -209,6 +300,8 @@ export default function Button({
|
||||
return "normal";
|
||||
}
|
||||
})()}
|
||||
{...loadingProps}
|
||||
className={twMerge("absolute", loadingProps?.className)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
@ -216,7 +309,13 @@ export default function Button({
|
||||
|
||||
if (href)
|
||||
return (
|
||||
<a {...linkProps} href={href} target={target}>
|
||||
<a
|
||||
{...linkProps}
|
||||
href={href}
|
||||
target={target}
|
||||
title={props.title}
|
||||
aria-label={props.title}
|
||||
>
|
||||
{buttonComponent}
|
||||
</a>
|
||||
);
|
||||
|
@ -13,7 +13,7 @@ export default function Center({
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center justify-center gap-4 p-2 w-full",
|
||||
"twui-center",
|
||||
"h-full twui-center",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
|
@ -1,28 +1,32 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
vertical?: boolean;
|
||||
dashed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Vertical and Horizontal Divider
|
||||
* @className twui-divider
|
||||
* @className twui-divider-horizontal
|
||||
* @className twui-divider-vertical
|
||||
*/
|
||||
export default function Divider({
|
||||
vertical,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
export default function Divider({ vertical, dashed, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"border-slate-200 dark:border-white/10 border-solid",
|
||||
"border-slate-200 dark:border-white/10",
|
||||
vertical
|
||||
? "border-0 border-l h-full min-h-5"
|
||||
: "border-0 border-t w-full",
|
||||
"twui-divider",
|
||||
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
|
||||
dashed ? "border-dashed" : "border-solid",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
|
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 (
|
||||
<h1
|
||||
{...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}
|
||||
</h1>
|
||||
|
@ -11,7 +11,12 @@ export default function H2({
|
||||
return (
|
||||
<h2
|
||||
{...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}
|
||||
</h2>
|
||||
|
@ -11,7 +11,12 @@ export default function H3({
|
||||
return (
|
||||
<h3
|
||||
{...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}
|
||||
</h3>
|
||||
|
@ -11,7 +11,12 @@ export default function H4({
|
||||
return (
|
||||
<h4
|
||||
{...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}
|
||||
</h4>
|
||||
|
@ -11,7 +11,12 @@ export default function H5({
|
||||
return (
|
||||
<h5
|
||||
{...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}
|
||||
</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>,
|
||||
HTMLImageElement
|
||||
> & {
|
||||
alt: string;
|
||||
size?: number;
|
||||
circle?: boolean;
|
||||
bgImg?: boolean;
|
||||
@ -24,6 +25,8 @@ export default function Img({ ...props }: TWUIImageProps) {
|
||||
const height = props.size || props.height;
|
||||
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
||||
|
||||
const [imageError, setImageError] = React.useState(false);
|
||||
|
||||
const finalProps = _.omit(props, [
|
||||
"size",
|
||||
"circle",
|
||||
@ -53,30 +56,70 @@ export default function Img({ ...props }: TWUIImageProps) {
|
||||
}
|
||||
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) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"hidden dark:block",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcDark}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"block dark:hidden",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcLight}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return <img {...interpolatedProps} />;
|
||||
return (
|
||||
<img
|
||||
{...interpolatedProps}
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
props.onError?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -9,29 +9,41 @@ type Props = DetailedHTMLProps<
|
||||
showArrow?: boolean;
|
||||
arrowSize?: number;
|
||||
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # General Anchor Elements
|
||||
* @className twui-a | twui-anchor
|
||||
* @info use `cancel-link` class name to prevent triggering this link from a child element
|
||||
*/
|
||||
export default function Link({
|
||||
showArrow,
|
||||
arrowSize = 20,
|
||||
arrowProps,
|
||||
strict,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-base text-link-500 no-underline hover:text-link-500/50",
|
||||
"text-blue-600 dark:text-blue-400 hover:opacity-60 transition-all",
|
||||
"border-0 border-b border-blue-300 dark:border-blue-200/30 border-solid leading-4",
|
||||
"text-link-500 no-underline hover:text-link-500/50",
|
||||
"text-link dark:text-link-dark hover:opacity-80 transition-all",
|
||||
"border-0 border-b border-link dark:border-link-dark border-solid leading-4",
|
||||
"twui-anchor",
|
||||
"twui-a",
|
||||
props.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.closest(".cancel-link")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
props?.onClick?.(e);
|
||||
}}
|
||||
data-strict={strict ? "yes" : undefined}
|
||||
>
|
||||
{props.children}
|
||||
{showArrow && (
|
||||
|
@ -1,18 +1,24 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLHeadingElement>,
|
||||
HTMLHeadingElement
|
||||
> & {
|
||||
noMargin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Paragraph Tag
|
||||
* @className twui-p | twui-paragraph
|
||||
*/
|
||||
export default function P({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
|
||||
export default function P({ noMargin, ...props }: Props) {
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-base py-4",
|
||||
"py-4",
|
||||
noMargin ? "!m-0 p-0" : "",
|
||||
"twui-p",
|
||||
"twui-paragraph",
|
||||
props.className
|
||||
|
@ -1,18 +1,26 @@
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
noWrap?: boolean;
|
||||
itemsStart?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Flexbox Row
|
||||
* @className twui-row
|
||||
*/
|
||||
export default function Row({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||
export default function Row({ noWrap, itemsStart, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
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",
|
||||
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
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-base",
|
||||
"",
|
||||
size == "small" && "text-sm",
|
||||
size == "smaller" && "text-xs",
|
||||
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
|
||||
> & {
|
||||
center?: boolean;
|
||||
gap?: number | string;
|
||||
componentRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Flexbox Column
|
||||
* @className twui-stack
|
||||
*/
|
||||
export default function Stack({ ...props }: Props) {
|
||||
export default function Stack({ gap, componentRef, ...props }: Props) {
|
||||
const finalProps = _.omit(props, "center");
|
||||
return (
|
||||
<div
|
||||
@ -21,9 +23,15 @@ export default function Stack({ ...props }: Props) {
|
||||
className={twMerge(
|
||||
"flex flex-col items-start gap-4",
|
||||
props.center && "items-center",
|
||||
gap
|
||||
? typeof gap == "string"
|
||||
? `gap-[${gap}]`
|
||||
: `gap-${gap}`
|
||||
: "",
|
||||
"twui-stack",
|
||||
props.className
|
||||
)}
|
||||
ref={componentRef}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
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 H4 from "../../layout/H4";
|
||||
import CodeBlock from "../../elements/CodeBlock";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Params = {
|
||||
components: MDXComponents;
|
||||
@ -17,13 +18,12 @@ export default function useMDXComponents({
|
||||
}: Params): MDXComponents {
|
||||
return {
|
||||
h1: ({ children }) => <H1>{children}</H1>,
|
||||
h2: ({ children }) => <H2>{children}</H2>,
|
||||
h3: ({ children }) => <H3>{children}</H3>,
|
||||
h4: ({ children }) => <H4>{children}</H4>,
|
||||
pre: ({ children, ...props }) => {
|
||||
if (React.isValidElement(children) && children.props) {
|
||||
return (
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
{/* @ts-ignore */}
|
||||
{children.props.children}
|
||||
</CodeBlock>
|
||||
);
|
||||
|
@ -2,26 +2,30 @@
|
||||
"name": "tailwind-ui",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.453.0",
|
||||
"react": "^19.0.0",
|
||||
"react-code-blocks": "^0.1.6",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-responsive-modal": "^6.4.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
"@xterm/xterm": "latest",
|
||||
"lodash": "latest",
|
||||
"lucide-react": "latest",
|
||||
"react-code-blocks": "latest",
|
||||
"react-responsive-modal": "latest",
|
||||
"tailwind-merge": "latest",
|
||||
"typescript": "latest",
|
||||
"mdx/types": "latest",
|
||||
"gray-matter": "latest",
|
||||
"next-mdx-remote": "latest",
|
||||
"remark-gfm": "latest",
|
||||
"rehype-prism-plus": "latest",
|
||||
"html-to-react": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ace": "^0.0.52",
|
||||
"@types/ace": "latest",
|
||||
"@types/bun": "latest",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@next/mdx": "^15.1.5"
|
||||
"@types/lodash": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "^4",
|
||||
"@types/mdx": "latest",
|
||||
"@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> {
|
||||
let data;
|
||||
|
||||
const csrfValue = localStorage.getItem(localStorageCSRFKey || "csrf");
|
||||
const csrfKey = "x-dsql-csrf-key";
|
||||
const csrfValue = localStorage.getItem(localStorageCSRFKey || csrfKey);
|
||||
|
||||
let finalHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
} as FetchHeader;
|
||||
|
||||
if (csrf && csrfValue) {
|
||||
finalHeaders[csrfHeaderKey || "x-csrf-key"] = csrfValue;
|
||||
finalHeaders[localStorageCSRFKey || csrfKey] = csrfValue;
|
||||
}
|
||||
|
||||
if (typeof options === "string") {
|
||||
|
@ -2,36 +2,38 @@ export type ImageInputToBase64FunctionReturn = {
|
||||
imageBase64?: string;
|
||||
imageBase64Full?: string;
|
||||
imageName?: string;
|
||||
imageType?: string;
|
||||
};
|
||||
|
||||
export type ImageInputToBase64FunctioParam = {
|
||||
imageInput: HTMLInputElement;
|
||||
imageInput?: HTMLInputElement;
|
||||
maxWidth?: number;
|
||||
mimeType?: string;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export default async function imageInputToBase64({
|
||||
imageInput,
|
||||
maxWidth,
|
||||
mimeType,
|
||||
file,
|
||||
}: ImageInputToBase64FunctioParam): Promise<ImageInputToBase64FunctionReturn> {
|
||||
try {
|
||||
if (!imageInput.files?.[0]) {
|
||||
throw new Error("No Files found in this image input");
|
||||
const finalFile = file || imageInput?.files?.[0];
|
||||
|
||||
if (!finalFile) {
|
||||
throw new Error("No Files found");
|
||||
}
|
||||
let imagePreviewNode = document.querySelector(
|
||||
`[data-imagepreview='image']`
|
||||
);
|
||||
let imageName = imageInput.files[0].name.replace(/\..*/, "");
|
||||
|
||||
let imageName = finalFile.name.replace(/\..*/, "");
|
||||
|
||||
let imageDataBase64: string | undefined;
|
||||
|
||||
const MIME_TYPE = mimeType ? mimeType : "image/jpeg";
|
||||
const MIME_TYPE = mimeType ? mimeType : finalFile.type;
|
||||
const QUALITY = 0.95;
|
||||
const MAX_WIDTH = maxWidth ? maxWidth : null;
|
||||
|
||||
const file = imageInput.files[0];
|
||||
const blobURL = URL.createObjectURL(file);
|
||||
const blobURL = URL.createObjectURL(finalFile);
|
||||
const img = new Image();
|
||||
|
||||
img.src = blobURL;
|
||||
@ -76,6 +78,7 @@ export default async function imageInputToBase64({
|
||||
imageBase64: imageDataBase64?.replace(/.*?base64,/, ""),
|
||||
imageBase64Full: imageDataBase64,
|
||||
imageName: imageName,
|
||||
imageType: MIME_TYPE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
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 = {
|
||||
/* config options here */
|
||||
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": {
|
||||
"@moduletrace/buncid": "^1.0.7",
|
||||
"@moduletrace/datasquirel": "^2.7.4",
|
||||
"@moduletrace/datasquirel": "^5.1.0",
|
||||
"@moduletrace/twui": "file:./components/lib",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "15.0.3",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"prism-themes": "^1.9.0",
|
||||
"react": "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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,25 @@
|
||||
import "@/styles/globals.css";
|
||||
import { PagePropsType } from "@/types";
|
||||
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) {
|
||||
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 = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
@ -1,14 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "../components/lib/base.css";
|
||||
|
||||
:root {
|
||||
--bg-color: #02030f;
|
||||
--header-height: 78px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-[var(--bg-color)] text-white;
|
||||
@theme inline {
|
||||
--color-primary: #02030f;
|
||||
}
|
||||
|
||||
.twui-button-general {
|
||||
@ -57,3 +54,7 @@ body {
|
||||
.twui-button-primary-ghost {
|
||||
@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