This commit is contained in:
Benjamin Toby 2025-07-20 10:35:54 +01:00
parent dd1d05251d
commit a0a0ab8ee4
99 changed files with 5678 additions and 909 deletions

BIN
bun.lockb

Binary file not shown.

View 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;
}

View 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
);
}

View 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
View 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
View 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
View 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=="],
}
}

View File

@ -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}

View File

@ -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>

View 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>
);
}

View File

@ -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>
);

View File

@ -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

View File

@ -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);
}
}
}

View 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;

View File

@ -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"

View File

@ -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>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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

View File

@ -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>
);
}

View File

@ -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>

View 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;
}

View 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>
);
}

View 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");
}
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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
)}

View 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>
);
}

View File

@ -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);
}

View 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>
);
}

View File

@ -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
)}

View 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 />;
}

View File

@ -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"

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View 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 };
}

View File

@ -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;
}
}, []);

View 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>
);
}

View File

@ -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>
);

View File

@ -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
)}
>

View File

@ -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
)}
/>

View 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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>
);
}

View File

@ -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);
}}
/>
);
}

View File

@ -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 && (

View File

@ -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

View File

@ -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
)}

View 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>
);
}

View File

@ -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",

View 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>
);
}

View File

@ -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>

View 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>
);
}

View 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}
/>
);
}

View 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
);
}

View File

@ -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.`} />;
}
}

View 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>
);
}

View 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)",
};
}

View File

@ -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>
);

View File

@ -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
View 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;

View 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());
}

View File

@ -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") {

View File

@ -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({
};
}
}
/** ********************************************** */
/** ********************************************** */
/** ********************************************** */

View File

@ -0,0 +1,6 @@
export default function twuiNormalizeText(txt: string) {
return txt
.replace(/\n|\r|\n\r/g, " ")
.replace(/ {2,}/g, " ")
.trim();
}

View 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;
}
}

View 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(" ");
}

View 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 "";
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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);

View File

@ -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"
}
}

View File

@ -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>
);
}

View 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
View 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,
},
};
};

View File

@ -1,8 +1,5 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@ -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
View 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;
};