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 ( return (
<aside <aside
{...props} {...props}
className={twMerge("py-10 hidden xl:flex", props.className)} className={twMerge(
"pb-10 hidden xl:flex sticky top-6",
props.className
)}
> >
<Stack> <Stack>
{before} {before}

View File

@ -8,7 +8,7 @@ import Stack from "../../layout/Stack";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Row from "../../layout/Row"; import Row from "../../layout/Row";
import Divider from "../../layout/Divider"; import Divider from "../../layout/Divider";
import { ChevronDown } from "lucide-react"; import { ChevronDown, Circle } from "lucide-react";
import Button from "../../layout/Button"; import Button from "../../layout/Button";
type Props = DetailedHTMLProps< type Props = DetailedHTMLProps<
@ -20,6 +20,7 @@ type Props = DetailedHTMLProps<
strict?: boolean; strict?: boolean;
childWrapperProps?: ComponentProps<typeof Stack>; childWrapperProps?: ComponentProps<typeof Stack>;
autoExpandAll?: boolean; autoExpandAll?: boolean;
child?: boolean;
}; };
/** /**
@ -34,6 +35,7 @@ export default function TWUIDocsLink({
childWrapperProps, childWrapperProps,
strict, strict,
autoExpandAll, autoExpandAll,
child,
...props ...props
}: Props) { }: Props) {
const [isActive, setIsActive] = React.useState(false); const [isActive, setIsActive] = React.useState(false);
@ -68,16 +70,19 @@ export default function TWUIDocsLink({
{...wrapperProps} {...wrapperProps}
> >
<Row className="flex-nowrap grow justify-between w-full"> <Row className="flex-nowrap grow justify-between w-full">
{child && <Circle size={6} />}
<a <a
href={docLink.href} href={docLink.href}
title={docLink.title}
{...props} {...props}
className={twMerge( className={twMerge(
"twui-docs-left-aside-link whitespace-nowrap", "twui-docs-left-aside-link whitespace-nowrap",
"grow", "grow overflow-hidden overflow-ellipsis",
isActive ? "active" : "", isActive ? "active" : "",
props.className props.className
)} )}
ref={linkRef} ref={linkRef}
data-strict={strict || docLink.strict}
> >
{docLink.title} {docLink.title}
</a> </a>
@ -91,6 +96,7 @@ export default function TWUIDocsLink({
expand ? "rotate-180 opacity-30" : "opacity-70" expand ? "rotate-180 opacity-30" : "opacity-70"
)} )}
onClick={() => setExpand(!expand)} onClick={() => setExpand(!expand)}
title="Docs Aside Links Dropdown Button"
> >
<ChevronDown className="text-slate-500" size={20} /> <ChevronDown className="text-slate-500" size={20} />
</Button> </Button>
@ -98,19 +104,20 @@ export default function TWUIDocsLink({
</Row> </Row>
{docLink.children && expand && ( {docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap"> <Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Divider vertical className="h-auto" />
<Stack <Stack
className={twMerge( className={twMerge(
"gap-2 w-full", "gap-2 w-full pl-3",
childWrapperProps?.className childWrapperProps?.className
)} )}
{...childWrapperProps} {...childWrapperProps}
> >
{docLink.children.map((link, index) => ( {docLink.children.map((link, index) => (
<TWUIDocsLink <TWUIDocsLink
docLink={link}
key={index} key={index}
className="text-sm" docLink={link}
className="text-sm opacity-70"
autoExpandAll={autoExpandAll}
child
/> />
))} ))}
</Stack> </Stack>

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 Stack from "../../layout/Stack";
import Container from "../../layout/Container"; import Container from "../../layout/Container";
import Row from "../../layout/Row"; import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import TWUIDocsAside from "./TWUIDocsAside"; import TWUIDocsAside from "./TWUIDocsAside";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Paper from "../../elements/Paper";
import TWUIDocsRightAside from "./TWUIDocsRightAside";
export type DocsLinkType = {
title: string;
href: string;
strict?: boolean;
children?: DocsLinkType[];
editPage?: string;
};
type Props = PropsWithChildren & { type Props = PropsWithChildren & {
DocsLinks: DocsLinkType[]; DocsLinks: DocsLinkType[];
@ -22,12 +31,7 @@ type Props = PropsWithChildren & {
HTMLElement HTMLElement
>; >;
autoExpandAll?: boolean; autoExpandAll?: boolean;
}; editPageURL?: string;
export type DocsLinkType = {
title: string;
href: string;
children?: DocsLinkType[];
}; };
/** /**
@ -43,6 +47,7 @@ export default function TWUIDocs({
docsContentProps, docsContentProps,
leftAsideProps, leftAsideProps,
autoExpandAll, autoExpandAll,
editPageURL,
}: Props) { }: Props) {
return ( return (
<Stack <Stack
@ -51,25 +56,32 @@ export default function TWUIDocs({
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)} className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
> >
<Container> <Container>
<Row <Paper className="xl:p-8 mobile-paper-hidden">
{...docsContentProps} <Row
className={twMerge( {...docsContentProps}
"items-stretch gap-6 w-full flex-nowrap", className={twMerge(
docsContentProps?.className "items-start gap-8 w-full flex-nowrap",
)} docsContentProps?.className
> )}
<TWUIDocsAside >
DocsLinks={DocsLinks} <TWUIDocsAside
after={docsAsideAfter} DocsLinks={DocsLinks}
before={docsAsideBefore} after={docsAsideAfter}
autoExpandAll={autoExpandAll} before={docsAsideBefore}
{...leftAsideProps} autoExpandAll={autoExpandAll}
/> {...leftAsideProps}
<Divider vertical className="h-auto hidden xl:flex" /> />
<div className="block twui-docs-content py-10 pl-0 xl:pl-6 grow"> <div
{children} className={twMerge(
</div> "block twui-docs-content pl-0 xl:pl-6 grow",
</Row> "overflow-hidden"
)}
>
{children}
</div>
<TWUIDocsRightAside editPageURL={editPageURL} />
</Row>
</Paper>
</Container> </Container>
</Stack> </Stack>
); );

View File

@ -1,5 +1,6 @@
import React, { MutableRefObject } from "react"; import React, { MutableRefObject } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import AceEditorModes from "./ace-editor-modes";
export type AceEditorComponentType = { export type AceEditorComponentType = {
editorRef?: MutableRefObject<AceAjax.Editor>; editorRef?: MutableRefObject<AceAjax.Editor>;
@ -8,11 +9,16 @@ export type AceEditorComponentType = {
ctrlEnterFn?: (editor: AceAjax.Editor) => void; ctrlEnterFn?: (editor: AceAjax.Editor) => void;
content?: string; content?: string;
placeholder?: string; placeholder?: string;
mode?: any; mode?: (typeof AceEditorModes)[number];
fontSize?: string; fontSize?: string;
previewMode?: boolean; previewMode?: boolean;
onChange?: (value: string) => void; onChange?: (value: string) => void;
delay?: number; delay?: number;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
refresh?: number;
}; };
let timeout: any; let timeout: any;
@ -34,10 +40,13 @@ export default function AceEditor({
previewMode, previewMode,
onChange, onChange,
delay = 500, delay = 500,
refresh: externalRefresh,
wrapperProps,
}: AceEditorComponentType) { }: AceEditorComponentType) {
try { try {
const editorElementRef = React.useRef<HTMLDivElement>(); const editorElementRef = React.useRef<HTMLDivElement>(null);
const editorRefInstance = React.useRef<AceAjax.Editor>(); // const editorRefInstance = React.useRef<AceAjax.Editor>(null);
const editorRefInstance = React.useRef<any>(null);
const [refresh, setRefresh] = React.useState(0); const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false); const [darkMode, setDarkMode] = React.useState(false);
@ -58,7 +67,7 @@ export default function AceEditor({
editor.setOptions({ editor.setOptions({
mode: `ace/mode/${mode ? mode : "javascript"}`, mode: `ace/mode/${mode ? mode : "javascript"}`,
theme: darkMode theme: darkMode
? "ace/theme/tomorrow_night_bright" ? "ace/theme/tomorrow_night_eighties"
: "ace/theme/ace_light", : "ace/theme/ace_light",
value: content, value: content,
placeholder: placeholder ? placeholder : "", placeholder: placeholder ? placeholder : "",
@ -89,14 +98,17 @@ export default function AceEditor({
setTimeout(() => { setTimeout(() => {
onChange(editor.getValue()); onChange(editor.getValue());
console.log(editor.getValue());
}, delay); }, delay);
} }
}); });
editorRefInstance.current = editor; editorRefInstance.current = editor;
if (editorRef) editorRef.current = editor; if (editorRef) editorRef.current = editor;
}, [refresh, darkMode, ready]);
return function () {
editor.destroy();
};
}, [refresh, darkMode, ready, externalRefresh]);
React.useEffect(() => { React.useEffect(() => {
const htmlClassName = document.documentElement.className; const htmlClassName = document.documentElement.className;
@ -109,10 +121,12 @@ export default function AceEditor({
return ( return (
<React.Fragment> <React.Fragment>
<div <div
{...wrapperProps}
className={twMerge( className={twMerge(
"w-full h-[400px] block rounded-md overflow-hidden", "w-full h-[400px] block rounded-default overflow-hidden",
"border border-slate-200 border-solid", "border border-slate-200 border-solid",
"dark:border-white/20" "dark:border-white/20",
wrapperProps?.className
)} )}
> >
<div <div

View File

@ -1,8 +1,10 @@
import React from "react"; import React, { ComponentProps } from "react";
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce"; import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import Border from "../../elements/Border";
export type TinyMCEEditorProps = { export type TinyMCEEditorProps<KeyType extends string> = {
tinyMCE?: TinyMCE | null; tinyMCE?: TinyMCE | null;
options?: RawEditorOptions; options?: RawEditorOptions;
editorRef?: React.MutableRefObject<Editor | null>; editorRef?: React.MutableRefObject<Editor | null>;
@ -11,7 +13,17 @@ export type TinyMCEEditorProps = {
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>; >;
wrapperWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
borderProps?: ComponentProps<typeof Border>;
defaultValue?: string; defaultValue?: string;
name?: KeyType;
changeHandler?: (content: string) => void;
showLabel?: boolean;
useParentCSS?: boolean;
placeholder?: string;
}; };
let interval: any; let interval: any;
@ -20,60 +32,158 @@ let interval: any;
* # Tiny MCE Editor Component * # Tiny MCE Editor Component
* @className_wrapper twui-rte-wrapper * @className_wrapper twui-rte-wrapper
*/ */
export default function TinyMCEEditor({ export default function TinyMCEEditor<KeyType extends string>({
options, options,
editorRef, editorRef,
setEditor, setEditor,
tinyMCE, tinyMCE,
wrapperProps, wrapperProps,
defaultValue, defaultValue,
}: TinyMCEEditorProps) { changeHandler,
wrapperWrapperProps,
borderProps,
name,
showLabel,
useParentCSS,
placeholder,
}: TinyMCEEditorProps<KeyType>) {
const editorComponentRef = React.useRef<HTMLDivElement>(null); const editorComponentRef = React.useRef<HTMLDivElement>(null);
const FINAL_HEIGHT = options?.height || 500; const FINAL_HEIGHT = options?.height || 500;
const [themeReady, setThemeReady] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [darkMode, setDarkMode] = React.useState(false);
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
React.useEffect(() => { React.useEffect(() => {
if (!editorComponentRef.current) { const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setThemeReady(true);
}, 200);
}, []);
React.useEffect(() => {
if (!editorComponentRef.current || !themeReady) {
return; return;
} }
tinyMCE?.init({ tinyMCE?.init({
height: FINAL_HEIGHT, height: FINAL_HEIGHT,
menubar: false, menubar: false,
plugins: [ plugins:
"advlist lists link image charmap print preview anchor", "advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
"searchreplace visualblocks code fullscreen",
"insertdatetime media table paste code help wordcount",
],
toolbar: toolbar:
"undo redo | blocks | bold italic | bullist numlist outdent indent | removeformat", "undo redo | blocks | bold italic underline link image | bullist numlist outdent indent | removeformat code searchreplace wordcount preview insertdatetime",
content_style: content_style:
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px }", "body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
init_instance_callback: (editor) => { init_instance_callback: (editor) => {
setEditor?.(editor as any); setEditor?.(editor as any);
if (editorRef) editorRef.current = editor as any; if (editorRef) editorRef.current = editor as any;
if (defaultValue) editor.setContent(defaultValue); if (defaultValue) editor.setContent(defaultValue);
setReady(true);
editor.on("input", (e) => {
changeHandler?.(editor.getContent());
});
if (useParentCSS) {
useParentStyles(editor);
}
}, },
base_url: "https://datasquirel.com/tinymce-public", base_url: "https://datasquirel.com/tinymce-public",
body_class: "twui-tinymce", body_class: "twui-tinymce",
placeholder,
relative_urls: true,
remove_script_host: true,
convert_urls: false,
...options, ...options,
license_key: "gpl", license_key: "gpl",
target: editorComponentRef.current, target: editorComponentRef.current,
content_css: darkMode ? "dark" : undefined,
skin: darkMode ? "oxide-dark" : undefined,
}); });
}, [tinyMCE]);
return function () {
tinyMCE?.remove();
};
}, [tinyMCE, themeReady]);
return ( return (
<div <div
{...wrapperProps} {...wrapperWrapperProps}
ref={editorComponentRef}
style={{
height: FINAL_HEIGHT + "px",
...wrapperProps?.style,
}}
className={twMerge( className={twMerge(
"bg-slate-200 dark:bg-slate-700 rounded-sm", "relative w-full [&_.tox-tinymce]:!border-none",
"twui-rte-wrapper" "bg-background-light dark:bg-background-dark",
wrapperWrapperProps?.className
)} )}
/> >
{showLabel && (
<label
className={twMerge(
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
"bg-background-light dark:bg-background-dark text-gray-500",
"dark:text-white/80 rounded"
)}
htmlFor={name || "twui-tinymce"}
>
{title}
</label>
)}
<Border
{...borderProps}
className={twMerge(
"dark:border-white/30 p-0 pt-2",
borderProps?.className
)}
>
<div
{...wrapperProps}
ref={editorComponentRef}
style={{
height:
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
...wrapperProps?.style,
}}
className={twMerge(
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
"twui-rte-wrapper"
)}
id={name || "twui-tinymce"}
></div>
</Border>
</div>
); );
} }
function useParentStyles(editor: Editor) {
const doc = editor.getDoc();
const parentStylesheets = document.styleSheets;
for (const sheet of parentStylesheets) {
try {
if (sheet.href) {
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = sheet.href;
doc.head.appendChild(link);
} else {
const rules = sheet.cssRules || sheet.rules;
if (rules) {
const style = doc.createElement("style");
for (const rule of rules) {
try {
style.appendChild(doc.createTextNode(rule.cssText));
} catch (e) {
console.warn("Could not copy CSS rule:", rule, e);
}
}
doc.head.appendChild(style);
}
}
} catch (e) {
console.warn("Error processing stylesheet:", sheet, e);
}
}
}

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 <div
{...props} {...props}
className={twMerge( className={twMerge(
"relative flex items-center gap-2 border border-solid rounded", "relative flex items-center gap-2 border border-solid rounded-default",
"border-slate-300 dark:border-white/10", "border-slate-200 dark:border-white/10",
spacing spacing
? spacing == "normal" ? spacing == "normal"
? "px-3 py-2" ? "px-3 py-2"

View File

@ -1,60 +1,63 @@
import React from "react"; import React, { ComponentProps, ReactNode } from "react";
import Link from "../layout/Link"; import Link from "../layout/Link";
import Divider from "../layout/Divider"; import Divider from "../layout/Divider";
import Row from "../layout/Row"; import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case"; import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = { type LinkObject = {
title: string; title: string;
path: string; path: string;
}; };
type Props = { type Props = {
excludeRegexMatch?: RegExp; excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
}; };
/** /**
* # TWUI Breadcrumbs * # TWUI Breadcrumbs
* @className `twui-current-breadcrumb-link` * @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper` * @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
*/ */
export default function Breadcrumbs({ excludeRegexMatch }: Props) { export default function Breadcrumbs({
const [links, setLinks] = React.useState<LinkObject[] | null>(null); excludeRegexMatch,
const [current, setCurrent] = React.useState(false); linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
: null
);
React.useEffect(() => { React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname; let pathname = window.location.pathname;
let pathLinks = pathname.split("/");
let validPathLinks = []; let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
validPathLinks.push({ excludeRegexMatch,
title: "Home", skipHome,
path: pathname.match(/admin/) ? "/admin" : "/",
});
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
}); });
setLinks(validPathLinks); setLinks(validPathLinks);
@ -69,13 +72,48 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
} }
return ( return (
<div <nav
className={twMerge( className={twMerge(
"overflow-x-auto max-w-[70vw]", "overflow-x-auto",
"twui-current-breadcrumb-wrapper" "twui-current-breadcrumb-wrapper"
)} )}
aria-label="Breadcrumb"
> >
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full"> <Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full"
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
backButtonProps?.className
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
>
<ChevronLeft size={20} />
</Button>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => { {links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index; const isTarget = array.length - 1 == index;
@ -84,13 +122,21 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
<Link <Link
key={index} key={index}
href={linkObject.path} href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge( className={twMerge(
"text-slate-400 dark:text-slate-500 pointer-events-none text-xs", "text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "", isTarget ? "current" : "",
"twui-current-breadcrumb-link" "twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)} )}
title={
currentLinkProps?.title || linkObject.title
}
> >
{linkObject.title} {currentTitle || linkObject.title}
</Link> </Link>
); );
} else { } else {
@ -98,30 +144,84 @@ export default function Breadcrumbs({ excludeRegexMatch }: Props) {
<React.Fragment key={index}> <React.Fragment key={index}>
<Link <Link
href={linkObject.path} href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge( className={twMerge(
"text-xs", "text-xs",
isTarget ? "current" : "", isTarget ? "current" : "",
"twui-current-breadcrumb-link" "twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className
)} )}
> >
{linkObject.title} {currentLinkProps?.title ||
linkObject.title}
</Link> </Link>
<Divider vertical /> {divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className
)}
/>
)}
</React.Fragment> </React.Fragment>
); );
} }
})} })}
</Row> </Row>
</div> </nav>
); );
//////////////////////////////////////// ////////////////////////////////////////
//////////////////////////////////////// ////////////////////////////////////////
//////////////////////////////////////// ////////////////////////////////////////
} }
/** ****************************************************************************** */ export function twuiBreadcrumbsGenerateLinksFromUrl({
/** ****************************************************************************** */ url,
/** ****************************************************************************** */ excludeRegexMatch,
/** ****************************************************************************** */ skipHome,
/** ****************************************************************************** */ }: {
/** ****************************************************************************** */ url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}

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 { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
type Props = DetailedHTMLProps< type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
@ -7,11 +12,10 @@ type Props = DetailedHTMLProps<
> & { > & {
variant?: "normal"; variant?: "normal";
href?: string; href?: string;
linkProps?: DetailedHTMLProps< linkProps?: ComponentProps<typeof Link>;
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
noHover?: boolean; noHover?: boolean;
elRef?: React.RefObject<HTMLDivElement>;
linkRef?: React.RefObject<HTMLAnchorElement>;
}; };
/** /**
@ -27,20 +31,18 @@ export default function Card({
variant, variant,
linkProps, linkProps,
noHover, noHover,
elRef,
linkRef,
...props ...props
}: Props) { }: Props) {
const component = ( const component = (
<div <div
ref={elRef}
{...props} {...props}
className={twMerge( className={twMerge(
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10", "flex flex-row items-center p-4 rounded-default bg-white dark:bg-white/10",
"border border-slate-200 dark:border-white/10 border-solid", "border border-slate-200 dark:border-white/10 border-solid",
noHover noHover ? "" : "twui-card",
? ""
: href
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
: "",
"twui-card",
props.className props.className
)} )}
> >
@ -50,28 +52,19 @@ export default function Card({
if (href) { if (href) {
return ( return (
<a <Link
ref={linkRef}
href={href} href={href}
{...linkProps} {...linkProps}
onClick={(e) => {
const targetEl = e.target as HTMLElement;
if (targetEl.closest(".nested-link")) {
e.preventDefault();
} else if (e.ctrlKey) {
window.open(href, "_blank");
} else {
window.location.href = href;
}
linkProps?.onClick?.(e);
}}
className={twMerge( className={twMerge(
"cursor-pointer", "cursor-pointer",
"twui-card",
"twui-card-link", "twui-card-link",
linkProps?.className linkProps?.className
)} )}
> >
{component} {component}
</a> </Link>
); );
} }

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, language,
...props ...props
}: Props) { }: Props) {
const codeRef = React.useRef<HTMLDivElement>(); const codeRef = React.useRef<HTMLDivElement>(null);
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
@ -52,9 +52,9 @@ export default function CodeBlock({
<div <div
{...wrapperProps} {...wrapperProps}
className={twMerge( className={twMerge(
"outline outline-[1px] outline-slate-200 dark:outline-white/10", "outline-[1px] outline-slate-200 dark:outline-white/10",
`rounded w-full transition-all items-start`, `rounded w-full transition-all items-start`,
"relative", "relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
"twui-code-block-wrapper", "twui-code-block-wrapper",
wrapperProps?.className wrapperProps?.className
)} )}
@ -62,7 +62,6 @@ export default function CodeBlock({
boxShadow: copied boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)" ? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined, : undefined,
maxWidth: "calc(100vw - 80px)",
backgroundColor: finalBackgroundColor, backgroundColor: finalBackgroundColor,
...props.style, ...props.style,
}} }}
@ -99,7 +98,7 @@ export default function CodeBlock({
variant="ghost" variant="ghost"
color="gray" color="gray"
beforeIcon={<Copy size={17} color="white" />} beforeIcon={<Copy size={17} color="white" />}
className="!p-1 !bg-transparent" className="!p-1 !bg-transparent opacity-50"
onClick={() => { onClick={() => {
const content = const content =
codeRef.current?.textContent; codeRef.current?.textContent;
@ -136,7 +135,7 @@ export default function CodeBlock({
<pre <pre
{...props} {...props}
className={twMerge( className={twMerge(
"!my-0", "!my-0 whitespace-pre-wrap",
language ? `language-${language}` : "", language ? `language-${language}` : "",
"twui-code-block-pre", "twui-code-block-pre",
props.className props.className

View File

@ -1,26 +1,71 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react"; import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle"; import { Moon, Sun } from "lucide-react";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
iconWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultScheme?: "light" | "dark";
};
/** /**
* # Color Scheme Loader * # Color Scheme Loader
* @className_wrapper twui-color-scheme-selector * @className_wrapper twui-color-scheme-selector
*/ */
export default function ColorSchemeSelector({ export default function ColorSchemeSelector({
active, active: initialActive,
setActive, setActive: externalSetActive,
toggleProps, iconWrapperProps,
defaultScheme,
...props ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { }: Props) {
toggleProps?: TWUI_TOGGLE_PROPS; const [active, setActive] = React.useState(initialActive);
active: boolean;
setActive: React.Dispatch<React.SetStateAction<boolean>>;
}) {
React.useEffect(() => { React.useEffect(() => {
const isDocumentDark =
document.documentElement.classList.contains("dark");
const isDocumentLight =
document.documentElement.classList.contains("light");
if (isDocumentDark) {
setActive(true);
return;
} else if (isDocumentLight) {
setActive(false);
return;
}
const existingTheme = localStorage.getItem("theme");
if (existingTheme === "dark") {
setActive(true);
} else if (existingTheme === "light") {
setActive(false);
} else if (defaultScheme) {
setActive(defaultScheme == "dark" ? false : true);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setActive(true);
} else if (typeof active == "undefined") {
setActive(false);
}
}, []);
React.useEffect(() => {
if (typeof active == "undefined") return;
if (active) { if (active) {
document.documentElement.className = "dark"; document.documentElement.className = "dark";
localStorage.setItem("theme", "dark");
} else { } else {
document.documentElement.className = ""; document.documentElement.className = "light";
localStorage.setItem("theme", "light");
} }
}, [active]); }, [active]);
@ -33,7 +78,24 @@ export default function ColorSchemeSelector({
props.className props.className
)} )}
> >
<Toggle active={active} setActive={setActive} {...toggleProps} /> <button
title="Color Scheme Selector Button"
onClick={() => setActive(!active)}
className={twMerge(
"cursor-pointer hover:opacity-70 flex items-center justify-center"
)}
>
<div
{...iconWrapperProps}
className={twMerge(
"w-6 h-6 flex items-center justify-center",
iconWrapperProps?.className
)}
>
{active == false && <Sun />}
{active == true && <Moon />}
</div>
</button>
</div> </div>
); );
} }

View File

@ -7,7 +7,11 @@ import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [ export const TWUIDropdownContentPositions = [
"left", "left",
"bottom-left",
"top-left",
"right", "right",
"bottom-right",
"top-right",
"center", "center",
] as const; ] as const;
@ -23,15 +27,17 @@ export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
HTMLDivElement HTMLDivElement
>; >;
debounce?: number; debounce?: number;
openDebounce?: number;
hoverOpen?: boolean; hoverOpen?: boolean;
above?: boolean; above?: boolean;
position?: (typeof TWUIDropdownContentPositions)[number]; position?: (typeof TWUIDropdownContentPositions)[number];
topOffset?: number; topOffset?: number;
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>; externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
externalOpen?: boolean;
keepOpen?: boolean;
disableClickActions?: boolean;
}; };
let timeout: any;
/** /**
* # Toggle Component * # Toggle Component
* @className_wrapper twui-dropdown-wrapper * @className_wrapper twui-dropdown-wrapper
@ -45,16 +51,24 @@ export default function Dropdown({
targetWrapperProps, targetWrapperProps,
hoverOpen, hoverOpen,
above, above,
debounce = 500, debounce = 200,
openDebounce = 200,
target, target,
position = "center", position = "center",
topOffset, topOffset,
externalSetOpen, externalSetOpen,
keepOpen,
disableClickActions,
externalOpen,
...props ...props
}: TWUI_DROPDOWN_PROPS) { }: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(externalOpen);
let timeout: any;
let openTimeout: any;
const dropdownRef = React.useRef<HTMLDivElement>(null); const dropdownRef = React.useRef<HTMLDivElement>(null);
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => { const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement; const targetEl = e.target as HTMLElement;
@ -71,12 +85,17 @@ export default function Dropdown({
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
if (keepOpen) return;
document.addEventListener("click", handleClickOutside); document.addEventListener("click", handleClickOutside);
return () => { return () => {
document.removeEventListener("click", handleClickOutside); document.removeEventListener("click", handleClickOutside);
}; };
}, []); }, []);
React.useEffect(() => {
setOpen(externalOpen);
}, [externalOpen]);
return ( return (
<div <div
{...props} {...props}
@ -88,11 +107,18 @@ export default function Dropdown({
onMouseEnter={() => { onMouseEnter={() => {
if (!hoverOpen) return; if (!hoverOpen) return;
window.clearTimeout(timeout); window.clearTimeout(timeout);
externalSetOpen?.(true); window.clearTimeout(openTimeout);
setOpen(true);
openTimeout = setTimeout(() => {
externalSetOpen?.(true);
setOpen(true);
}, openDebounce);
}} }}
onMouseLeave={() => { onMouseLeave={(e) => {
if (!hoverOpen) return; if (!hoverOpen) return;
window.clearTimeout(openTimeout);
timeout = setTimeout(() => { timeout = setTimeout(() => {
externalSetOpen?.(false); externalSetOpen?.(false);
setOpen(false); setOpen(false);
@ -107,6 +133,7 @@ export default function Dropdown({
onClick={(e) => { onClick={(e) => {
const targetEl = e.target as HTMLElement | null; const targetEl = e.target as HTMLElement | null;
if (targetEl?.closest?.(".cancel-link")) return; if (targetEl?.closest?.(".cancel-link")) return;
if (disableClickActions) return;
externalSetOpen?.(!open); externalSetOpen?.(!open);
setOpen(!open); setOpen(!open);
}} }}
@ -122,12 +149,18 @@ export default function Dropdown({
<div <div
{...contentWrapperProps} {...contentWrapperProps}
className={twMerge( className={twMerge(
"absolute z-10", "absolute z-10 mt-1",
position == "left" position == "left"
? "left-0" ? "left-[100%] top-[50%] -translate-y-[50%]"
: position == "right" : position == "right"
? "right-0" ? "right-[100%] top-[50%] -translate-y-[50%]"
: "", : position == "bottom-left"
? "left-0 top-[100%]"
: position == "bottom-right"
? "right-0 top-[100%]"
: position == "center"
? "left-[50%] -translate-x-[50%] top-[100%]"
: "top-[100%]",
above ? "-translate-y-[120%]" : "", above ? "-translate-y-[120%]" : "",
open ? "flex" : "hidden", open ? "flex" : "hidden",
"twui-dropdown-content", "twui-dropdown-content",
@ -142,9 +175,10 @@ export default function Dropdown({
window.clearTimeout(timeout); window.clearTimeout(timeout);
}} }}
style={{ style={{
top: `calc(100% + ${topOffset || 0}px)`, // top: `calc(100% + ${topOffset || 0}px)`,
...contentWrapperProps?.style, ...contentWrapperProps?.style,
}} }}
ref={dropdownContentRef}
> >
{props.children} {props.children}
</div> </div>

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 <svg
aria-hidden="true" aria-hidden="true"
className={twMerge( className={twMerge(
"text-gray-200 animate-spin dark:text-gray-600 fill-blue-600", "text-gray animate-spin dark:text-gray-dark fill-primary",
"twui-loading", "dark:fill-white twui-loading",
sizeClassName, sizeClassName,
svgClassName svgClassName
)} )}

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 React, { DetailedHTMLProps, HTMLAttributes } from "react";
import ModalComponent from "../(partials)/ModalComponent";
import PopoverComponent from "../(partials)/PopoverComponent";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createRoot } from "react-dom/client";
import Paper from "./Paper";
type Props = DetailedHTMLProps< export const TWUIPopoverStyles = [
"top",
"bottom",
"left",
"right",
"transform",
"bottom-left",
"bottom-right",
] as const;
export const TWUIPopoverTriggers = ["hover", "click"] as const;
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
> & { > & {
target: React.ReactNode; target?: React.ReactNode;
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>; targetRef?: React.RefObject<HTMLDivElement>;
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
targetWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
open?: boolean;
isPopover?: boolean;
position?: (typeof TWUIPopoverStyles)[number];
trigger?: (typeof TWUIPopoverTriggers)[number];
debounce?: number;
onClose?: () => any;
}; };
/** /**
* # Modal Component * # Modal Component
* @className_wrapper twui-modal-root * @ID twui-modal-root
* @className_wrapper twui-modal * @className twui-modal-content
* @className twui-modal
* @ID twui-popover-root
* @className twui-popover-content
* @className twui-popover-target
*/ */
export default function Modal({ target, targetRef, ...props }: Props) { export default function Modal(props: TWUI_MODAL_PROPS) {
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null); const {
target,
targetRef,
targetWrapperProps,
open: existingOpen,
setOpen: existingSetOpen,
isPopover,
popoverReferenceRef,
trigger = "hover",
debounce = 500,
onClose,
} = props;
const [ready, setReady] = React.useState(false);
const [open, setOpen] = React.useState(existingOpen || false);
React.useEffect(() => { React.useEffect(() => {
const wrapperEl = document.createElement("div"); const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
const modalRoot = document.getElementById(IDName);
wrapperEl.className = twMerge( if (modalRoot) {
"fixed z-[200000] top-0 left-0 w-screen h-screen", setReady(true);
"flex flex-col items-center justify-center", } else {
"twui-modal-root" const newModalRootEl = document.createElement("div");
); newModalRootEl.id = IDName;
document.body.appendChild(newModalRootEl);
setWrapper(wrapperEl); setReady(true);
}
}, []); }, []);
const modalEl = ( React.useEffect(() => {
existingSetOpen?.(open);
if (open == false) onClose?.();
}, [open]);
React.useEffect(() => {
setOpen(existingOpen || false);
}, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
const popoverContentActiveRef = React.useRef(false);
let closeTimeout: any;
const popoverEnterFn = React.useCallback((e: any) => {
popoverTargetActiveRef.current = true;
popoverContentActiveRef.current = false;
setOpen(true);
props.onMouseEnter?.(e);
}, []);
const popoverLeaveFn = React.useCallback((e: any) => {
window.clearTimeout(closeTimeout);
closeTimeout = setTimeout(() => {
// if (popoverTargetActiveRef.current) {
// popoverTargetActiveRef.current = false;
// return;
// }
if (popoverContentActiveRef.current) {
popoverContentActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
props.onMouseLeave?.(e);
}, []);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
const closestWrapper = targetEl.closest(".twui-popover-content");
const closestTarget = targetEl.closest(".twui-popover-target");
if (closestTarget) return;
if (!closestWrapper) {
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (!isPopover) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
return (
<React.Fragment> <React.Fragment>
<div {target ? (
className={twMerge( <div
"absolute top-0 left-0 bg-slate-900/80 z-0", {...targetWrapperProps}
"w-screen h-screen" onClick={(e) => setOpen(!open)}
)} ref={finalTargetRef}
onClick={(e) => { onMouseEnter={
closeModal({ wrapperEl: wrapper }); isPopover && trigger === "hover"
}} ? popoverEnterFn
></div> : targetWrapperProps?.onMouseEnter
<Paper }
{...props} onMouseLeave={
className={twMerge("z-10 max-w-[500px]", props.className)} isPopover && trigger === "hover"
> ? popoverLeaveFn
{props.children} : targetWrapperProps?.onMouseLeave
</Paper> }
className={twMerge(
"twui-popover-target",
targetWrapperProps?.className
)}
>
{target}
</div>
) : null}
{ready ? (
isPopover ? (
<PopoverComponent
{...props}
open={open}
setOpen={setOpen}
targetElRef={finalPopoverReferenceRef}
debounce={debounce}
popoverTargetActiveRef={popoverTargetActiveRef}
popoverContentActiveRef={popoverContentActiveRef}
/>
) : (
<ModalComponent {...props} open={open} setOpen={setOpen} />
)
) : null}
</React.Fragment> </React.Fragment>
); );
const targetEl = (
<div
onClick={(e) => {
if (!wrapper) return;
document.body.appendChild(wrapper);
const root = createRoot(wrapper);
root.render(modalEl);
}}
ref={targetRef as any}
>
{target}
</div>
);
return targetEl;
}
function closeModal({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
if (!wrapperEl) return;
wrapperEl.parentElement?.removeChild(wrapperEl);
} }

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"; import { twMerge } from "tailwind-merge";
/** /**
@ -8,6 +8,7 @@ import { twMerge } from "tailwind-merge";
export default function Paper({ export default function Paper({
variant, variant,
linkProps, linkProps,
componentRef,
...props ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
variant?: "normal"; variant?: "normal";
@ -15,13 +16,16 @@ export default function Paper({
React.AnchorHTMLAttributes<HTMLAnchorElement>, React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement HTMLAnchorElement
>; >;
componentRef?: RefObject<HTMLDivElement | null>;
}) { }) {
return ( return (
<div <div
{...props} {...props}
ref={componentRef as any}
className={twMerge( className={twMerge(
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4", "flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full", "border border-slate-200 dark:border-white/10 border-solid w-full",
"relative",
"twui-paper", "twui-paper",
props.className props.className
)} )}

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 Button from "../layout/Button";
import Row from "../layout/Row"; import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react"; import { Search as SearchIcon } from "lucide-react";
import React, { import React, { DetailedHTMLProps } from "react";
DetailedHTMLProps,
InputHTMLAttributes,
TextareaHTMLAttributes,
} from "react";
let timeout: any; let timeout: any;
@ -16,12 +12,15 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
HTMLDivElement HTMLDivElement
> & { > & {
dispatch?: (value?: string) => void; dispatch?: (value?: string) => void;
changeHandler?: (value?: string) => void;
delay?: number; delay?: number;
inputProps?: InputProps<KeyType>; inputProps?: InputProps<KeyType>;
buttonProps?: DetailedHTMLProps< buttonProps?: DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
>; >;
loading?: boolean;
placeholder?: string;
}; };
/** /**
@ -32,9 +31,12 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
*/ */
export default function Search<KeyType extends string>({ export default function Search<KeyType extends string>({
dispatch, dispatch,
changeHandler,
delay = 500, delay = 500,
inputProps, inputProps,
buttonProps, buttonProps,
loading,
placeholder,
...props ...props
}: SearchProps<KeyType>) { }: SearchProps<KeyType>) {
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState("");
@ -44,10 +46,11 @@ export default function Search<KeyType extends string>({
timeout = setTimeout(() => { timeout = setTimeout(() => {
dispatch?.(input); dispatch?.(input);
changeHandler?.(input);
}, delay); }, delay);
}, [input]); }, [input]);
const inputRef = React.useRef<HTMLInputElement>(); const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (props.autoFocus) { if (props.autoFocus) {
@ -66,7 +69,7 @@ export default function Search<KeyType extends string>({
> >
<Input <Input
type="search" type="search"
placeholder="Search" placeholder={placeholder || "Search"}
{...inputProps} {...inputProps}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
@ -81,17 +84,21 @@ export default function Search<KeyType extends string>({
componentRef={inputRef} componentRef={inputRef}
/> />
<Button <Button
loadingProps={{ size: "small" }}
{...buttonProps} {...buttonProps}
variant="outlined" variant="outlined"
color="gray" color="gray"
className={twMerge( className={twMerge(
"rounded-l-none my-[1px]", "rounded-l-none ml-[1px]",
"twui-search-button", "twui-search-button",
buttonProps?.className buttonProps?.className
)} )}
onClick={() => { onClick={() => {
dispatch?.(input); dispatch?.(input);
changeHandler?.(input);
}} }}
title="Search Button"
loading={loading}
> >
<SearchIcon <SearchIcon
className="text-slate-800 dark:text-white" className="text-slate-800 dark:text-white"

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 { twMerge } from "tailwind-merge";
import Border from "./Border"; import Border from "./Border";
import Stack from "../layout/Stack"; import Stack from "../layout/Stack";
import Row from "../layout/Row"; import Row from "../layout/Row";
import Span from "../layout/Span"; import twuiSlugify from "../utils/slugify";
export type TWUITabsObject = { export type TWUITabsObject = {
title: string; title: string;
value: string; value?: string;
content: React.ReactNode; content: React.ReactNode;
defaultActive?: boolean; defaultActive?: boolean;
}; };
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & { export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
tabsContentArray: TWUITabsObject[]; tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
tabsBorderProps?: React.ComponentProps<typeof Border>; tabsBorderProps?: React.ComponentProps<typeof Border>;
tabsButtonsWrapperProps?: React.DetailedHTMLProps< tabsButtonsWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
@ -21,6 +21,11 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
>; >;
centered?: boolean; centered?: boolean;
debounce?: number; debounce?: number;
/**
* React Component to display when switching
*/
switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
}; };
/** /**
@ -36,20 +41,37 @@ export default function Tabs({
tabsButtonsWrapperProps, tabsButtonsWrapperProps,
centered, centered,
debounce = 100, debounce = 100,
switchComponent,
setActiveValue: existingSetActiveValue,
...props ...props
}: TWUI_TOGGLE_PROPS) { }: TWUI_TOGGLE_PROPS) {
const values = tabsContentArray.map((obj) => obj.value); const finalTabsContentArray = tabsContentArray
.flat()
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
const values = finalTabsContentArray.map(
(obj) => obj.value || twuiSlugify(obj.title)
);
const defaultActiveObj = finalTabsContentArray.find(
(ctn) => ctn.defaultActive
);
const [activeValue, setActiveValue] = React.useState( const [activeValue, setActiveValue] = React.useState(
tabsContentArray.find((ctn) => ctn.defaultActive)?.value || defaultActiveObj
values[0] || ? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
undefined : values[0] || undefined
); );
const targetContent = tabsContentArray.find( const targetContent = finalTabsContentArray.find(
(ctn) => ctn.value == activeValue (ctn) =>
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue
); );
React.useEffect(() => {
existingSetActiveValue?.(activeValue);
}, [activeValue]);
return ( return (
<Stack <Stack
{...props} {...props}
@ -63,16 +85,21 @@ export default function Tabs({
tabsButtonsWrapperProps?.className tabsButtonsWrapperProps?.className
)} )}
> >
<Border className="p-0 w-full" {...tabsBorderProps}> <Border
className="p-0 w-full overflow-hidden"
{...tabsBorderProps}
>
<Row <Row
className={twMerge( className={twMerge(
"gap-0 items-stretch w-full", "gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
centered && "justify-center" centered && "justify-center"
)} )}
> >
{values.map((value, index) => { {values.map((value, index) => {
const targetObject = tabsContentArray.find( const targetObject = finalTabsContentArray.find(
(ctn) => ctn.value == value (ctn) =>
ctn.value == value ||
twuiSlugify(ctn.title) == value
); );
const isActive = value == activeValue; const isActive = value == activeValue;
@ -80,9 +107,9 @@ export default function Tabs({
return ( return (
<span <span
className={twMerge( className={twMerge(
"px-6 py-2 rounded -ml-[1px]", "px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
isActive isActive
? "bg-blue-500 text-white outline-none twui-tab-button-active" ? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" + : "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer", " cursor-pointer",
"twui-tab-buttons" "twui-tab-buttons"
@ -102,7 +129,7 @@ export default function Tabs({
</Row> </Row>
</Border> </Border>
</div> </div>
{targetContent?.content} {activeValue ? targetContent?.content : switchComponent || null}
</Stack> </Stack>
); );
} }

View File

@ -16,57 +16,68 @@ export type TWUI_TOGGLE_PROPS = PropsWithChildren &
color?: "normal" | "secondary" | "error" | "success" | "gray"; color?: "normal" | "secondary" | "error" | "success" | "gray";
variant?: "normal" | "outlined" | "ghost"; variant?: "normal" | "outlined" | "ghost";
href?: string; href?: string;
newTab?: boolean;
linkProps?: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
}; };
/** /**
* # Tabs Component * # Tabs Component
* @className twui-tag * @className twui-tag
* @className twui-tag-primary-outlined
*/ */
export default function Tag({ export default function Tag({
color, color,
variant, variant,
children, children,
href, href,
newTab,
linkProps,
...props ...props
}: TWUI_TOGGLE_PROPS) { }: TWUI_TOGGLE_PROPS) {
const mainComponent = ( const mainComponent = (
<div <div
{...props} {...props}
className={twMerge( className={twMerge(
"text-xs px-2 py-0.5 rounded-full outline outline-0", "text-xs px-2 py-0.5 rounded-full outline-0",
"text-center flex items-center justify-center", "text-center flex items-center justify-center",
color == "secondary" color == "secondary"
? "bg-violet-600 outline-violet-600" ? "bg-secondary text-white outline-secbg-secondary"
: color == "success" : color == "success"
? "bg-emerald-700 outline-emerald-700" ? "bg-success outline-success text-white"
: color == "error" : color == "error"
? "bg-orange-700 outline-orange-700" ? "bg-orange-700 outline-orange-700"
: color == "gray" : color == "gray"
? "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20 text-slate-500 dark:text-white" ? twMerge(
: "bg-blue-600 outline-blue-600", "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20",
"text-slate-800 dark:text-white"
)
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
variant == "outlined" variant == "outlined"
? "!bg-transparent outline-1 " + ? "!bg-transparent outline-1 " +
(color == "secondary" (color == "secondary"
? "text-violet-600" ? "text-secondary"
: color == "success" : color == "success"
? "text-emerald-700 dark:text-emerald-400" ? "text-success dark:text-success-dark"
: color == "error" : color == "error"
? "text-orange-700" ? "text-orange-700"
: color == "gray" : color == "gray"
? "text-slate-700 dark:text-white/80" ? "text-slate-700 dark:text-white/80"
: "text-blue-600") : "text-primary dark:text-primary-dark twui-tag-primary-outlined")
: variant == "ghost" : variant == "ghost"
? "!bg-transparent outline-none border-none " + ? "!bg-transparent outline-none border-none " +
(color == "secondary" (color == "secondary"
? "text-violet-600" ? "text-secondary"
: color == "success" : color == "success"
? "text-emerald-700 dark:text-emerald-400" ? "text-success dark:text-success-dark"
: color == "error" : color == "error"
? "text-orange-700" ? "text-orange-700"
: color == "gray" : color == "gray"
? "text-slate-700 dark:text-white/80" ? "text-slate-700 dark:text-white/80"
: "text-blue-600") : "text-primary dark:text-primary-dark")
: "text-white", : "",
"twui-tag", "twui-tag",
props.className props.className
@ -78,7 +89,12 @@ export default function Tag({
if (href) { if (href) {
return ( return (
<a href={href} className={twMerge("hover:opacity-80")}> <a
href={href}
target={newTab ? "_blank" : undefined}
{...linkProps}
className={twMerge("hover:opacity-80", linkProps?.className)}
>
{mainComponent} {mainComponent}
</a> </a>
); );

View File

@ -1,8 +1,8 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react"; import React, { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createRoot } from "react-dom/client";
import Card from "./Card"; import Card from "./Card";
import { X } from "lucide-react"; import { X } from "lucide-react";
import ReactDOM from "react-dom";
import Span from "../layout/Span"; import Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const; export const ToastStyles = ["normal", "success", "error"] as const;
@ -35,17 +35,47 @@ export default function Toast({
color, color,
...props ...props
}: TWUIToastProps) { }: TWUIToastProps) {
const [ready, setReady] = React.useState(false);
const IDName = "twui-toast-root";
React.useEffect(() => {
const toastRoot = document.getElementById(IDName);
if (toastRoot) {
setReady(true);
} else {
const newToastRootEl = document.createElement("div");
newToastRootEl.id = IDName;
document.body.appendChild(newToastRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
if (!ready || !open) return;
timeout = setTimeout(() => {
setOpen?.(false);
}, closeDelay);
return function () {
setOpen?.(false);
};
}, [ready, open]);
if (!ready) return null;
if (!open) return null; if (!open) return null;
const toastEl = ( return ReactDOM.createPortal(
<Card <Card
{...props} {...props}
className={twMerge( className={twMerge(
"pl-6 pr-8 py-4 bg-blue-700 dark:bg-blue-800", "absolute bottom-4 right-4 z-[250] border-none",
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
color == "success" color == "success"
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success" ? "bg-success dark:bg-success-dark twui-toast-success"
: color == "error" : color == "error"
? "bg-orange-600 dark:bg-orange-700 twui-toast-error" ? "bg-error dark:bg-error-dark twui-toast-error"
: "", : "",
props.className, props.className,
"twui-toast" "twui-toast"
@ -54,13 +84,7 @@ export default function Toast({
window.clearTimeout(timeout); window.clearTimeout(timeout);
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
const targetEl = e.target as HTMLElement;
const rootWrapperEl = targetEl.closest(
".twui-toast-root"
) as HTMLDivElement | null;
timeout = setTimeout(() => { timeout = setTimeout(() => {
closeToast({ wrapperEl: rootWrapperEl });
setOpen?.(false); setOpen?.(false);
}, closeDelay); }, closeDelay);
}} }}
@ -71,48 +95,13 @@ export default function Toast({
"text-white" "text-white"
)} )}
onClick={(e) => { onClick={(e) => {
const targetEl = e.target as HTMLElement; setOpen?.(false);
const rootWrapperEl = targetEl.closest(".twui-toast-root");
if (rootWrapperEl) {
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
setOpen?.(false);
}
}} }}
> >
<X size={15} /> <X size={15} />
</Span> </Span>
<Span className={twMerge("text-white")}>{props.children}</Span> <Span className={twMerge("text-white")}>{props.children}</Span>
</Card> </Card>,
document.getElementById(IDName) as HTMLElement
); );
React.useEffect(() => {
const wrapperEl = document.createElement("div");
wrapperEl.className = twMerge(
"fixed z-[200000] bottom-10 right-10",
"flex flex-col items-center justify-center",
"twui-toast-root"
);
document.body.appendChild(wrapperEl);
const root = createRoot(wrapperEl);
root.render(toastEl);
timeout = setTimeout(() => {
closeToast({ wrapperEl });
setOpen?.(false);
}, closeDelay);
return function () {
closeToast({ wrapperEl });
};
}, []);
return null;
}
function closeToast({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
if (!wrapperEl) return;
wrapperEl.parentElement?.removeChild(wrapperEl);
} }

View File

@ -6,7 +6,7 @@ export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLDivElement HTMLDivElement
> & { > & {
active?: boolean; active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>; setActive?: React.Dispatch<React.SetStateAction<boolean | undefined>>;
circleProps?: DetailedHTMLProps< circleProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
@ -36,17 +36,21 @@ export default function Toggle({
)} )}
onClick={() => setActive?.(!active)} onClick={() => setActive?.(!active)}
> >
<div {typeof active == "undefined" ? (
{...circleProps} <div className="w-3.5 h-3.5 twui-toggle-circle"></div>
className={twMerge( ) : (
"w-3.5 h-3.5 rounded-full ", <div
active {...circleProps}
? "bg-blue-600 dark:bg-blue-500" className={twMerge(
: "bg-slate-300 dark:bg-white/40", "w-3.5 h-3.5 rounded-full ",
"twui-toggle-circle", active
circleProps?.className ? "bg-blue-600 dark:bg-blue-500"
)} : "bg-slate-300 dark:bg-white/40",
></div> "twui-toggle-circle",
circleProps?.className
)}
></div>
)}
</div> </div>
); );
} }

View File

@ -1,29 +1,38 @@
import React, { import React, {
ComponentProps,
DetailedHTMLProps, DetailedHTMLProps,
HTMLAttributes, HTMLAttributes,
InputHTMLAttributes,
ReactNode, ReactNode,
} from "react"; } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import CheckMarkSVG from "../svgs/CheckMarkSVG"; import CheckMarkSVG from "../svgs/CheckMarkSVG";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import { Info } from "lucide-react";
import Span from "../layout/Span";
export type CheckboxProps = DetailedHTMLProps< export type CheckboxProps = React.DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLInputElement HTMLDivElement
> & { > & {
name: string;
wrapperProps?: DetailedHTMLProps< wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>; >;
label?: string | ReactNode; label?: string | ReactNode;
labelProps?: DetailedHTMLProps< labelProps?: React.DetailedHTMLProps<
HTMLAttributes<HTMLLabelElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLLabelElement HTMLDivElement
>; >;
defaultChecked?: boolean; defaultChecked?: boolean;
wrapperClassName?: string; wrapperClassName?: string;
setChecked?: React.Dispatch<React.SetStateAction<boolean>>; setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean;
readOnly?: boolean;
size?: number;
changeHandler?: (value: boolean) => void;
info?: string | ReactNode;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
}; };
/** /**
@ -37,62 +46,92 @@ export default function Checkbox({
label, label,
labelProps, labelProps,
size, size,
name,
wrapperClassName, wrapperClassName,
defaultChecked, defaultChecked,
setChecked, setChecked: externalSetChecked,
readOnly,
checked: externalChecked,
changeHandler,
info,
wrapperWrapperProps,
...props ...props
}: CheckboxProps) { }: CheckboxProps) {
const finalSize = size || 20; const finalSize = size || 20;
const [internalChecked, setInternalChecked] = React.useState( const [checked, setChecked] = React.useState(
defaultChecked || false defaultChecked || externalChecked || false
); );
const checkMarkRef = React.useRef<HTMLInputElement>(); const finalTitle = props.title
? props.title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => {
if (typeof externalChecked == "undefined") return;
setChecked(externalChecked);
}, [externalChecked]);
React.useEffect(() => {
changeHandler?.(checked);
}, [checked]);
return ( return (
<div <Stack
{...wrapperProps} {...wrapperWrapperProps}
onClick={(e) => { className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
checkMarkRef.current?.click();
wrapperProps?.onClick?.(e);
}}
className={twMerge(
"flex items-center gap-2",
wrapperClassName,
wrapperProps?.className
)}
> >
<input
type="checkbox"
{...props}
width={finalSize}
height={finalSize}
className={twMerge("hidden")}
name={name}
onChange={(e) => {
setInternalChecked(e.target.checked);
setChecked?.(e.target.checked);
}}
ref={checkMarkRef as any}
/>
<div <div
{...wrapperProps}
className={twMerge( className={twMerge(
"flex items-center justify-center p-[3px] rounded", "flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
internalChecked readOnly ? "opacity-70 pointer-events-none" : "",
? "bg-emerald-700 twui-checkbox-checked" wrapperClassName,
: "outline-slate-600 dark:outline-white/50 outline-2 outline -outline-offset-2 twui-checkbox-unchecked", wrapperProps?.className
"twui-checkbox"
)} )}
style={{ onClick={() => {
width: finalSize + "px", setChecked(!checked);
height: finalSize + "px", externalSetChecked?.(!checked);
}} }}
> >
{internalChecked && <CheckMarkSVG />} <div
{...props}
className={twMerge(
"flex items-center justify-center p-[3px] rounded-default",
checked
? "bg-primary twui-checkbox-checked text-white outline-slate-400"
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
"twui-checkbox",
props.className
)}
style={{
minWidth: finalSize + "px",
width: finalSize + "px",
height: finalSize + "px",
...props.style,
}}
>
{checked && <CheckMarkSVG />}
</div>
<Stack className="gap-0.5">
<div
{...labelProps}
className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className
)}
>
{label || finalTitle}
</div>
</Stack>
</div> </div>
{label && <label>{label}</label>} {info && (
</div> <Row className="gap-1" title={info.toString()}>
<Info size={12} className="opacity-40" />
<Span size="smaller" className="opacity-70">
{info}
</Span>
</Row>
)}
</Stack>
); );
} }

View File

@ -1,31 +1,25 @@
import Button from "../layout/Button"; import Button from "../layout/Button";
import Stack from "../layout/Stack"; import Stack from "../layout/Stack";
import { import { FileArchive, FilePlus2, X } from "lucide-react";
File, import React, { ComponentProps, DetailedHTMLProps, ReactNode } from "react";
FileArchive,
FilePlus,
FilePlus2,
ImagePlus,
X,
} from "lucide-react";
import React, { DetailedHTMLProps } from "react";
import Card from "../elements/Card"; import Card from "../elements/Card";
import Span from "../layout/Span"; import Span from "../layout/Span";
import Center from "../layout/Center"; import Center from "../layout/Center";
import imageInputToBase64, { import { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
FileInputToBase64FunctionReturn,
} from "../utils/form/fileInputToBase64";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import fileInputToBase64 from "../utils/form/fileInputToBase64"; import fileInputToBase64 from "../utils/form/fileInputToBase64";
import Row from "../layout/Row"; import Row from "../layout/Row";
import Input from "./Input";
import Loading from "../elements/Loading";
type ImageUploadProps = DetailedHTMLProps< type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
> & { > & {
onChangeHandler?: ( onChangeHandler?: (
imgData: FileInputToBase64FunctionReturn | undefined fileData: FileInputToBase64FunctionReturn | undefined
) => any; ) => any;
onClear?: () => void;
fileInputProps?: DetailedHTMLProps< fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
@ -42,12 +36,21 @@ type ImageUploadProps = DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>, React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement HTMLImageElement
>; >;
label?: string; label?: string | ReactNode;
disablePreview?: boolean; disablePreview?: boolean;
allowedRegex?: RegExp; allowedRegex?: RegExp;
externalSetFile?: React.Dispatch< externalSetFile?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn | undefined> React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
>; >;
externalSetFiles?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
>;
existingFile?: FileInputToBase64FunctionReturn;
existingFileUrl?: string;
icon?: ReactNode;
labelSpanProps?: ComponentProps<typeof Span>;
loading?: boolean;
multiple?: boolean;
}; };
/** /**
@ -63,12 +66,37 @@ export default function FileUpload({
disablePreview, disablePreview,
allowedRegex, allowedRegex,
externalSetFile, externalSetFile,
externalSetFiles,
existingFile,
existingFileUrl,
icon,
labelSpanProps,
loading,
multiple,
onClear,
...props ...props
}: ImageUploadProps) { }: ImageUploadProps) {
const [file, setFile] = React.useState< const [file, setFile] = React.useState<
FileInputToBase64FunctionReturn | undefined FileInputToBase64FunctionReturn | undefined
>(undefined); >(existingFile);
const inputRef = React.useRef<HTMLInputElement>(); const [fileUrl, setFileUrl] = React.useState<string | undefined>(
existingFileUrl
);
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (existingFileUrl) {
setFileUrl(existingFileUrl);
}
}, [existingFileUrl]);
React.useEffect(() => {
if (existingFile) {
setFile(existingFile);
}
}, [existingFile]);
return ( return (
<Stack <Stack
@ -77,26 +105,117 @@ export default function FileUpload({
> >
<input <input
type="file" type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)} className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps} {...fileInputProps}
onChange={(e) => { onChange={(e) => {
const inputFile = e.target.files?.[0]; if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
if (!inputFile) return; let filesArr: FileInputToBase64FunctionReturn[] =
[];
fileInputToBase64({ inputFile, allowedRegex }).then( for (let i = 0; i < files.length; i++) {
(res) => { const file = files[i];
setFile(res); const fileObj = await fileInputToBase64({
externalSetFile?.(res); inputFile: file,
onChangeHandler?.(res); });
fileInputProps?.onChange?.(e); filesArr.push(fileObj);
} }
);
externalSetFiles?.(filesArr);
})();
} else {
const inputFile = e.target.files?.[0];
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
fileInputProps?.onChange?.(e);
}
);
}
}} }}
ref={inputRef as any} ref={inputRef as any}
/> />
{file ? ( {loading ? (
<Card className={twMerge("w-full h-full ")}>
<Center>
<Loading />
</Center>
</Card>
) : file ? (
<Card
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
"pb-10",
previewImageWrapperProps?.className
)}
>
<Stack>
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : file.fileType?.match(/image/i) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
<Stack>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span>
{file.file?.name || file.fileName}
</Span>
<Span size="smaller" className="opacity-70">
{file.fileType}
</Span>
</Stack>
</Stack>
)}
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"hover:bg-white dark:hover:bg-black"
)}
onClick={(e) => {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}}
title="Cancel File Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
<Input
value={file.fileName}
onChange={(e) => {
setFile({ ...file, fileName: e.target.value });
externalSetFile?.({
...file,
fileName: e.target.value,
});
}}
/>
</Stack>
</Card>
) : fileUrl ? (
<Card <Card
className="w-full relative h-full items-center justify-center overflow-hidden" className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps} {...previewImageWrapperProps}
@ -105,22 +224,21 @@ export default function FileUpload({
<Span className="opacity-50" size="small"> <Span className="opacity-50" size="small">
Image Uploaded! Image Uploaded!
</Span> </Span>
) : file.fileType?.match(/image/i) ? ( ) : fileUrl.match(/\.pdf$|\.txt$/) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
<Row> <Row>
<FileArchive size={36} strokeWidth={1} /> <FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0"> <Stack className="gap-0">
<Span>{file.file?.name || file.fileName}</Span>
<Span size="smaller" className="opacity-70"> <Span size="smaller" className="opacity-70">
{file.fileType} {fileUrl}
</Span> </Span>
</Stack> </Stack>
</Row> </Row>
) : (
<img
src={fileUrl}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
)} )}
<Button <Button
variant="ghost" variant="ghost"
@ -132,7 +250,9 @@ export default function FileUpload({
setFile(undefined); setFile(undefined);
externalSetFile?.(undefined); externalSetFile?.(undefined);
onChangeHandler?.(undefined); onChangeHandler?.(undefined);
setFileUrl(undefined);
}} }}
title="Cancel File Button"
> >
<X className="text-slate-950 dark:text-white" /> <X className="text-slate-950 dark:text-white" />
</Button> </Button>
@ -140,19 +260,63 @@ export default function FileUpload({
) : ( ) : (
<Card <Card
className={twMerge( className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20", "w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
placeHolderWrapper?.className placeHolderWrapper?.className
)} )}
onClick={(e) => { onClick={(e) => {
inputRef.current?.click(); inputRef.current?.click();
placeHolderWrapper?.onClick?.(e); placeHolderWrapper?.onClick?.(e);
}} }}
onDragOver={(e) => {
e.preventDefault();
setFileDraggedOver(true);
}}
onDragLeave={(e) => {
setFileDraggedOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setFileDraggedOver(false);
let inputFile: File | null = null;
if (e.dataTransfer.items) {
[...e.dataTransfer.items].forEach((item, i) => {
if (inputFile) return;
if (item.kind === "file") {
const file = item.getAsFile();
inputFile = file;
}
});
} else {
inputFile = e.dataTransfer.files?.[0];
}
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
}
);
}}
{...placeHolderWrapper} {...placeHolderWrapper}
> >
<Center> <Center
className={twMerge(
fileDraggedOver ? "pointer-events-none" : ""
)}
>
<Stack className="items-center gap-2"> <Stack className="items-center gap-2">
<FilePlus2 className="text-slate-400" /> {icon || <FilePlus2 className="text-slate-400" />}
<Span size="smaller" variant="faded"> <Span
size="smaller"
variant="faded"
{...labelSpanProps}
>
{label || "Click to Upload File"} {label || "Click to Upload File"}
</Span> </Span>
</Stack> </Stack>

View File

@ -2,16 +2,20 @@ import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes } from "react"; import { DetailedHTMLProps, FormHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
};
/** /**
* # Form Element * # Form Element
* @className twui-form * @className twui-form
*/ */
export default function Form<T extends object = { [key: string]: any }>({ export default function Form<
...props T extends { [key: string]: any } = { [key: string]: any }
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & { >({ ...props }: Props<T>) {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void; const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
}) {
const finalProps = _.omit(props, "submitHandler");
return ( return (
<form <form
@ -29,6 +33,15 @@ export default function Form<T extends object = { [key: string]: any }>({
props.submitHandler?.(e, data); props.submitHandler?.(e, data);
props.onSubmit?.(e); props.onSubmit?.(e);
}} }}
onChange={(e) => {
e.preventDefault();
const taregtEl = e.target as HTMLElement;
const formEl = taregtEl.closest("form") as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.changeHandler?.(e, data);
props.onChange?.(e);
}}
> >
{props.children} {props.children}
</form> </form>

View File

@ -9,6 +9,7 @@ import imageInputToBase64, {
ImageInputToBase64FunctionReturn, ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64"; } from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag";
type ImageUploadProps = DetailedHTMLProps< type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
@ -35,6 +36,17 @@ type ImageUploadProps = DetailedHTMLProps<
>; >;
label?: string; label?: string;
disablePreview?: boolean; disablePreview?: boolean;
multiple?: boolean;
existingImageUrl?: string;
externalSetImage?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
>;
externalSetImages?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
}; };
/** /**
@ -48,53 +60,118 @@ export default function ImageUpload({
previewImageProps, previewImageProps,
label, label,
disablePreview, disablePreview,
existingImageUrl,
externalSetImage,
externalSetImages,
externalImage,
multiple,
restoreImageFn,
setLoading,
...props ...props
}: ImageUploadProps) { }: ImageUploadProps) {
const [src, setSrc] = React.useState<string | undefined>(undefined); const [imageObject, setImageObject] = React.useState<
const inputRef = React.useRef<HTMLInputElement>(); ImageInputToBase64FunctionReturn | undefined
>(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl);
}, [existingImageUrl]);
return ( return (
<Stack <Stack
{...props} {...props}
className={twMerge("w-full h-[300px]", props?.className)} className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className
)}
> >
<input <input
type="file" type="file"
className={twMerge("hidden", fileInputProps?.className)} className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps} {...fileInputProps}
onChange={(e) => { onChange={(e) => {
imageInputToBase64({ imageInput: e.target }).then((res) => { setLoading?.(true);
setSrc(res.imageBase64Full);
onChangeHandler?.(res); if (multiple) {
fileInputProps?.onChange?.(e); (async () => {
}); const files = e.target.files;
if (!files?.[0]) return;
let imgArr: ImageInputToBase64FunctionReturn[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await imageInputToBase64({
file,
});
imgArr.push(fileObj);
}
externalSetImages?.(imgArr);
setLoading?.(false);
})();
} else {
imageInputToBase64({ imageInput: e.target }).then(
(res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
setImageObject?.(res);
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
}
);
}
}} }}
ref={inputRef as any} ref={inputRef as any}
/> />
{src ? ( {src || imageObject?.imageBase64Full ? (
<Card <Card
className="w-full relative h-full items-center justify-center" className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps} {...previewImageWrapperProps}
> >
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50"
)}
>
<Tag color="gray">
<span className="opacity-70">{label}</span>
</Tag>
</label>
)}
{disablePreview ? ( {disablePreview ? (
<Span className="opacity-50" size="small"> <Span className="opacity-50" size="small">
Image Uploaded! Image Uploaded!
</Span> </Span>
) : ( ) : (
<img <img
src={src} src={imageObject?.imageBase64Full || src}
className="w-full object-contain" className="w-full h-full object-contain"
{...previewImageProps} {...previewImageProps}
/> />
)} )}
<Button <Button
variant="ghost" variant="ghost"
className="absolute p-2 top-2 right-2 z-20" className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark"
)}
onClick={(e) => { onClick={(e) => {
setSrc(undefined); setSrc(undefined);
onChangeHandler?.(undefined); onChangeHandler?.(undefined);
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
}
}} }}
title="Cancel Image Upload Button"
> >
<X className="text-slate-950 dark:text-white" /> <X className="text-slate-950 dark:text-white" />
</Button> </Button>
@ -106,6 +183,11 @@ export default function ImageUpload({
placeHolderWrapper?.className placeHolderWrapper?.className
)} )}
onClick={(e) => { onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
if (targetEl?.closest(".cancel-upload")) {
e.preventDefault();
return;
}
inputRef.current?.click(); inputRef.current?.click();
placeHolderWrapper?.onClick?.(e); placeHolderWrapper?.onClick?.(e);
}} }}
@ -117,6 +199,20 @@ export default function ImageUpload({
<Span size="smaller" variant="faded"> <Span size="smaller" variant="faded">
{label || "Click to Upload Image"} {label || "Click to Upload Image"}
</Span> </Span>
{existingImageUrl && (
<Button
title="Restore Image Button"
size="smaller"
variant="ghost"
onClick={() => {
restoreImageFn?.() ||
setSrc(existingImageUrl);
}}
className="cancel-upload"
>
Restore Original Image
</Button>
)}
</Stack> </Stack>
</Center> </Center>
</Card> </Card>

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 { ChevronDown, Info, LucideProps } from "lucide-react";
import { import React, {
ComponentProps,
DetailedHTMLProps, DetailedHTMLProps,
ForwardRefExoticComponent, Dispatch,
InputHTMLAttributes, InputHTMLAttributes,
LabelHTMLAttributes, LabelHTMLAttributes,
RefAttributes, ReactNode,
RefObject, RefObject,
SelectHTMLAttributes, SelectHTMLAttributes,
SetStateAction,
} from "react"; } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Dropdown from "../elements/Dropdown";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Stack from "../layout/Stack";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
type SelectOptionObject = { export type TWUISelectValidityObject = {
title: string; isValid?: boolean;
value: string; msg?: string;
default?: boolean;
}; };
type SelectProps = DetailedHTMLProps< export type TWUISelectOptionObject<
KeyType extends string,
T extends { [k: string]: any } = any
> = {
title?: string;
value: KeyType;
default?: boolean;
data?: T;
};
export type TWUISelectProps<
KeyType extends string,
T extends { [k: string]: any } = any
> = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>, SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement HTMLSelectElement
> & { > & {
options: SelectOptionObject[]; options: TWUISelectOptionObject<KeyType, T>[];
label?: string; label?: string;
showLabel?: boolean; showLabel?: boolean;
wrapperProps?: DetailedHTMLProps< wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>, InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>; >;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps< labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>, LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement HTMLLabelElement
>; >;
componentRef?: RefObject<HTMLSelectElement>; componentRef?: RefObject<HTMLSelectElement>;
iconProps?: LucideProps; iconProps?: LucideProps;
changeHandler?: (value: SelectProps["options"][number]["value"]) => void; changeHandler?: (value: KeyType, data?: T) => void;
info?: string | ReactNode;
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
name?: KeyType;
};
export type TWUISelectValueObject<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
> = {
value: KeyType;
data?: T;
}; };
/** /**
@ -42,7 +76,10 @@ type SelectProps = DetailedHTMLProps<
* @className twui-select * @className twui-select
* @className twui-select-dropdown-icon * @className twui-select-dropdown-icon
*/ */
export default function Select({ export default function Select<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any }
>({
label, label,
options, options,
componentRef, componentRef,
@ -51,76 +88,164 @@ export default function Select({
showLabel, showLabel,
iconProps, iconProps,
changeHandler, changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props ...props
}: SelectProps) { }: TWUISelectProps<KeyType, T>) {
return ( const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
<div isValid: true,
{...wrapperProps} });
className={twMerge(
"relative w-full flex items-center",
wrapperProps?.className
)}
>
{showLabel && (
<label
htmlFor={props.name}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-slate-500 bg-white px-1.5 rounded-t",
"dark:text-white/60 dark:bg-black",
"twui-input-label",
labelProps?.className
)}
>
{label || props.name}
</label>
)}
<select const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
{...props}
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value,
data: options[0]?.data,
}
);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
dispatchState?.(value.data);
}, [value]);
const selectID = label
? twuiSlugify(label)
: props.name
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge( className={twMerge(
"w-full pl-3 py-2 border rounded-md appearance-none pr-8", "relative w-full flex items-center border rounded-default",
"border-slate-300 dark:border-white/20", "border-slate-300 dark:border-white/20 pr-2",
"focus:border-slate-700 dark:focus:border-white/50", "focus:border-slate-700 dark:focus:border-white/50",
"outline-slate-300 dark:outline-white/20", "outline-slate-300 dark:outline-white/20",
"focus:outline-slate-700 dark:focus:outline-white/50", "focus:outline-slate-700 dark:focus:outline-white/50",
"bg-white dark:bg-black", "bg-white dark:bg-background-dark",
"twui-select", validity.isValid ? "" : "outline-warning border-warning",
props.className wrapperProps?.className
)} )}
ref={componentRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
changeHandler?.(
e.target.value as (typeof options)[number]["value"]
);
props.onChange?.(e);
}}
> >
{options.flat().map((option, index) => { {showLabel && (
return ( <label
<option htmlFor={selectID}
key={index} {...labelProps}
value={option.value} className={twMerge(
// selected={option.default || undefined} "text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
> "dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
{option.title} "twui-input-label",
</option> labelProps?.className
); )}
})} >
</select> {label || props.title || props.name}
</label>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"absolute right-2 pointer-events-none",
iconProps?.className
)} )}
/>
</div> <select
id={selectID}
{...props}
className={twMerge(
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
"grow !border-none !outline-none",
"twui-select",
props.className
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
const targetValue = options.find(
(opt) => opt.value == e.target.value
);
if (targetValue) {
setValue(targetValue);
}
changeHandler?.(
e.target.value as (typeof options)[number]["value"],
targetValue?.data
);
props.onChange?.(e);
validateValueFn?.(e.target.value).then((res) => {
setValidity(res);
});
}}
>
{options.flat().map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);
return (
<option key={index} value={option.value}>
{optionTitle}
</option>
);
})}
</select>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"pointer-events-none -ml-6",
iconProps?.className
)}
/>
{info && (
<Dropdown
target={
<div title="Select Info Button">
<Info size={20} />
</div>
}
hoverOpen
>
<Card className="min-w-[250px] text-sm p-6">
{typeof info == "string" ? (
<Span className="text-sm">{info}</Span>
) : (
info
)}
</Card>
</Dropdown>
)}
</div>
{!validity.isValid && validity.msg ? (
<Span size="smaller" className="text-warning">
{validity.msg}
</Span>
) : undefined}
</Stack>
); );
} }

View File

@ -1,31 +1,42 @@
import React from "react"; import React from "react";
type Param = { type Param = {
elementRef?: React.MutableRefObject<Element | undefined>; elementRef?: React.RefObject<Element | undefined>;
className?: string; className?: string;
elId?: string;
options?: IntersectionObserverInit; options?: IntersectionObserverInit;
removeIntersected?: boolean; removeIntersected?: boolean;
delay?: number;
}; };
let timeout: any;
export default function useIntersectionObserver({ export default function useIntersectionObserver({
elementRef, elementRef,
className, className,
options, options,
removeIntersected, removeIntersected,
delay,
elId,
}: Param) { }: Param) {
const [isIntersecting, setIsIntersecting] = React.useState(false); const [isIntersecting, setIsIntersecting] = React.useState(false);
const [refresh, setRefresh] = React.useState(0); const [refresh, setRefresh] = React.useState(0);
const observerTriggerDelay = delay || 200;
const observerCallback: IntersectionObserverCallback = React.useCallback( const observerCallback: IntersectionObserverCallback = React.useCallback(
(entries, observer) => { (entries, observer) => {
const entry = entries[0]; const entry = entries[0];
window.clearTimeout(timeout);
if (entry.isIntersecting) { if (entry.isIntersecting) {
setIsIntersecting(true); timeout = setTimeout(() => {
setIsIntersecting(true);
if (removeIntersected) { if (removeIntersected) {
observer.unobserve(entry.target); observer.unobserve(entry.target);
} }
}, observerTriggerDelay);
} else { } else {
setIsIntersecting(false); setIsIntersecting(false);
} }
@ -34,7 +45,9 @@ export default function useIntersectionObserver({
); );
React.useEffect(() => { React.useEffect(() => {
const element = elementRef?.current; const element = elId
? document.getElementById(elId)
: elementRef?.current;
const elements = className const elements = className
? document.querySelectorAll(`.${className}`) ? document.querySelectorAll(`.${className}`)
: null; : null;

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; debounce?: number;
url: string; url: string;
disableReconnect?: boolean; disableReconnect?: boolean;
keepAliveDuration?: number;
refreshConnection?: number;
}; };
let reconnectInterval: any;
let msgInterval: any;
let sendInterval: any;
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const; export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
let tries = 0; let tries = 0;
@ -28,8 +26,25 @@ let tries = 0;
*/ */
export default function useWebSocket< export default function useWebSocket<
T extends { [key: string]: any } = { [key: string]: any } T extends { [key: string]: any } = { [key: string]: any }
>({ url, debounce, disableReconnect }: UseWebsocketHookParams) { >({
url,
debounce,
disableReconnect,
keepAliveDuration,
refreshConnection,
}: UseWebsocketHookParams) {
const DEBOUNCE = debounce || 200; const DEBOUNCE = debounce || 200;
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3;
const KEEP_ALIVE_MESSAGE = "twui::ping";
let uptime = 0;
let reconnectInterval: any;
let msgInterval: any;
let sendInterval: any;
let keepAliveInterval: any;
const [socket, setSocket] = React.useState<WebSocket | undefined>( const [socket, setSocket] = React.useState<WebSocket | undefined>(
undefined undefined
@ -38,6 +53,9 @@ export default function useWebSocket<
const messageQueueRef = React.useRef<string[]>([]); const messageQueueRef = React.useRef<string[]>([]);
const sendMessageQueueRef = React.useRef<string[]>([]); const sendMessageQueueRef = React.useRef<string[]>([]);
/**
* # Dispatch Custom Event
*/
const dispatchCustomEvent = React.useCallback( const dispatchCustomEvent = React.useCallback(
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => { (evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
const event = new CustomEvent(evtName, { const event = new CustomEvent(evtName, {
@ -51,6 +69,9 @@ export default function useWebSocket<
[] []
); );
/**
* # Connect to Websocket
*/
const connect = React.useCallback(() => { const connect = React.useCallback(() => {
const wsURL = url; const wsURL = url;
if (!wsURL) return; if (!wsURL) return;
@ -59,22 +80,41 @@ export default function useWebSocket<
ws.onopen = (ev) => { ws.onopen = (ev) => {
window.clearInterval(reconnectInterval); window.clearInterval(reconnectInterval);
window.clearInterval(keepAliveInterval);
keepAliveInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(KEEP_ALIVE_MESSAGE);
uptime += KEEP_ALIVE_DURATION;
if (uptime >= KEEP_ALIVE_TIMEOUT) {
console.log("Websocket connection timed out ...");
window.clearInterval(keepAliveInterval);
ws.close();
}
}
}, KEEP_ALIVE_DURATION);
setSocket(ws); setSocket(ws);
tries = 0; tries = 0;
console.log(`Websocket connected to ${wsURL}`); console.log(`Websocket connected to ${wsURL}`);
uptime = 0;
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
window.clearInterval(msgInterval); window.clearInterval(msgInterval);
messageQueueRef.current.push(ev.data); messageQueueRef.current.push(ev.data);
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE); msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE);
if (ev.data !== KEEP_ALIVE_MESSAGE) {
uptime = 0;
}
}; };
ws.onclose = (ev) => { ws.onclose = (ev) => {
console.log("Websocket closed!");
if (disableReconnect) return; if (disableReconnect) return;
console.log("Websocket closed ... Attempting to reconnect ..."); console.log("Attempting to reconnect ...");
console.log("URL:", url); console.log("URL:", url);
window.clearInterval(keepAliveInterval);
reconnectInterval = setInterval(() => { reconnectInterval = setInterval(() => {
if (tries >= 3) { if (tries >= 3) {
@ -89,6 +129,31 @@ export default function useWebSocket<
}; };
}, []); }, []);
/**
* # Window Close Handler
*/
const handleWindowClose = React.useCallback(() => {
console.log("Window Unloaded ...");
}, [socket]);
/**
* # Window Focus Handler
*/
const handleWindowFocus = React.useCallback(() => {
if (socket?.readyState === WebSocket.CLOSED) {
console.log("Websocket closed ... Attempting to reconnect ...");
connect();
}
if (socket?.readyState === WebSocket.OPEN) {
console.log("Websocket connection alive ...");
socket.send(KEEP_ALIVE_MESSAGE);
uptime = 0;
}
}, [socket]);
/**
* # Initial Connection
*/
React.useEffect(() => { React.useEffect(() => {
connect(); connect();
@ -97,6 +162,36 @@ export default function useWebSocket<
}; };
}, []); }, []);
/**
* # Window Close and Focus Handlers
*/
React.useEffect(() => {
if (!socket) return;
window.addEventListener("beforeunload", handleWindowClose, {
once: true,
});
window.addEventListener("focus", handleWindowFocus);
return function () {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener("beforeunload", handleWindowClose);
};
}, [socket]);
/**
* # Refresh Connection
*/
React.useEffect(() => {
console.log("Refreshing connection ...");
if (!socket) return;
if (socket.readyState !== WebSocket.CLOSED) {
socket?.close();
}
connect();
}, [refreshConnection]);
/** /**
* Received Message Queue Handler * Received Message Queue Handler
*/ */
@ -113,6 +208,7 @@ export default function useWebSocket<
} }
} else { } else {
window.clearInterval(msgInterval); window.clearInterval(msgInterval);
uptime = 0;
} }
}, []); }, []);

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 { import {
AnchorHTMLAttributes, AnchorHTMLAttributes,
ButtonHTMLAttributes, ButtonHTMLAttributes,
ComponentProps,
DetailedHTMLProps, DetailedHTMLProps,
HTMLAttributeAnchorTarget, HTMLAttributeAnchorTarget,
HTMLAttributes, HTMLAttributes,
@ -12,10 +13,13 @@ export type TWUIButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>, ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
> & { > & {
title: string;
variant?: "normal" | "ghost" | "outlined"; variant?: "normal" | "ghost" | "outlined";
color?: color?:
| "primary" | "primary"
| "secondary" | "secondary"
| "text"
| "white"
| "accent" | "accent"
| "gray" | "gray"
| "error" | "error"
@ -36,6 +40,7 @@ export type TWUIButtonProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>; >;
loadingProps?: ComponentProps<typeof Loading>;
}; };
/** /**
@ -48,12 +53,48 @@ export type TWUIButtonProps = DetailedHTMLProps<
* @className twui-button-secondary * @className twui-button-secondary
* @className twui-button-secondary-outlined * @className twui-button-secondary-outlined
* @className twui-button-secondary-ghost * @className twui-button-secondary-ghost
* @className twui-button-white
* @className twui-button-white-outlined
* @className twui-button-white-ghost
* @className twui-button-accent * @className twui-button-accent
* @className twui-button-accent-outlined * @className twui-button-accent-outlined
* @className twui-button-accent-ghost * @className twui-button-accent-ghost
* @className twui-button-gray * @className twui-button-gray
* @className twui-button-gray-outlined * @className twui-button-gray-outlined
* @className twui-button-gray-ghost * @className twui-button-gray-ghost
*
* @example
```css
CSS directive:
//@theme inline {
--breakpoint-xs: 350px;
--color-primary: #000000;
--color-primary-hover: #29292b;
--color-primary-outline: #29292b;
--color-primary-text: #29292b;
--color-primary-dark: #29292b;
--color-primary-dark-hover: #4b4b4b;
--color-primary-dark-outline: #4b4b4b;
--color-primary-dark-text: #4b4b4b;
--color-secondary: #000000;
--color-secondary-hover: #dddddd;
--color-secondary-outline: #dddddd;
--color-secondary-text: #dddddd;
--color-secondary-dark: #000000;
--color-secondary-dark-hover: #dddddd;
--color-secondary-dark-outline: #dddddd;
--color-secondary-dark-text: #dddddd;
--color-accent: #000000;
--color-accent-hover: #dddddd;
--color-accent-outline: #dddddd;
--color-accent-text: #dddddd;
--color-accent-dark: #000000;
--color-accent-dark-hover: #dddddd;
--color-accent-dark-outline: #dddddd;
--color-accent-dark-text: #dddddd;
}
```
*/ */
export default function Button({ export default function Button({
href, href,
@ -67,47 +108,67 @@ export default function Button({
afterIcon, afterIcon,
loading, loading,
loadingIconSize, loadingIconSize,
loadingProps,
...props ...props
}: TWUIButtonProps) { }: TWUIButtonProps) {
const finalClassName: string = (() => { const finalClassName: string = (() => {
if (variant == "normal" || !variant) { if (variant == "normal" || !variant) {
if (color == "primary" || !color) if (color == "primary" || !color)
return twMerge( return twMerge(
"bg-blue-500 hover:bg-blue-600 text-white", "bg-primary hover:bg-primary-hover text-white",
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white",
"twui-button-primary" "twui-button-primary"
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-emerald-500 hover:bg-emerald-600 text-white", "bg-secondary hover:bg-secondary-hover text-white",
"twui-button-secondary" "twui-button-secondary"
); );
if (color == "white")
return twMerge(
"!bg-white hover:!bg-slate-200 !text-slate-800",
"twui-button-white"
);
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-violet-500 hover:bg-violet-600 text-white", "bg-accent hover:bg-accent-hover text-white",
"twui-button-accent" "twui-button-accent"
); );
if (color == "gray") if (color == "gray")
return twMerge( return twMerge(
"bg-slate-300 hover:bg-slate-200 text-slate-800", "bg-gray hover:bg-gray-hover text-foreground-light",
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
"twui-button-gray" "twui-button-gray"
); );
if (color == "success")
return twMerge(
"bg-success hover:bg-success-hover text-white",
"dark:bg-success hover:dark:bg-success-hover text-white",
"twui-button-success"
);
if (color == "error")
return twMerge(
"bg-error hover:bg-error-hover text-white",
"dark:bg-error hover:dark:bg-error-hover text-white",
"twui-button-error"
);
} else if (variant == "outlined") { } else if (variant == "outlined") {
if (color == "primary" || !color) if (color == "primary" || !color)
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-blue-500", "bg-transparent outline outline-1 outline-primary",
"text-blue-500 dark:text-blue-400 dark:outline-blue-300", "text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
"twui-button-primary-outlined" "twui-button-primary-outlined"
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-emerald-500", "bg-transparent outline outline-1 outline-secondary",
"text-emerald-500", "text-secondary",
"twui-button-secondary-outlined" "twui-button-secondary-outlined"
); );
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-violet-500", "bg-transparent outline outline-1 outline-accent",
"text-violet-500", "text-accent",
"twui-button-accent-outlined" "twui-button-accent-outlined"
); );
if (color == "gray") if (color == "gray")
@ -116,23 +177,41 @@ export default function Button({
"text-slate-600 dark:text-white/60 dark:outline-white/30", "text-slate-600 dark:text-white/60 dark:outline-white/30",
"twui-button-gray-outlined" "twui-button-gray-outlined"
); );
if (color == "white")
return twMerge(
"bg-transparent outline outline-1 outline-white/50",
"text-white",
"twui-button-white-outlined"
);
if (color == "error")
return twMerge(
"bg-transparent outline outline-1 outline-error text-error",
"dark:outline-error dark:text-error-dark",
"twui-button-error-outlined"
);
} else if (variant == "ghost") { } else if (variant == "ghost") {
if (color == "primary" || !color) if (color == "primary" || !color)
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-blue-500 hover:bg-transparent dark:hover:bg-transparent", "text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
"twui-button-primary-ghost" "twui-button-primary-ghost"
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-emerald-500 hover:bg-transparent dark:hover:bg-transparent", "text-secondary hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost"
);
if (color == "text")
return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 dark:text-foreground-dark",
"text-foreground-light hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost" "twui-button-secondary-ghost"
); );
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-violet-500 hover:bg-transparent dark:hover:bg-transparent", "text-accent hover:bg-transparent dark:hover:bg-transparent",
"twui-button-accent-ghost" "twui-button-accent-ghost"
); );
if (color == "gray") if (color == "gray")
@ -156,9 +235,15 @@ export default function Button({
if (color == "success") if (color == "success")
return twMerge( return twMerge(
"bg-transparent outline-none p-2", "bg-transparent outline-none p-2",
"text-emerald-600", "text-success",
"twui-button-success-ghost" "twui-button-success-ghost"
); );
if (color == "white")
return twMerge(
"bg-transparent outline-none p-2",
"text-white",
"twui-button-white-ghost"
);
} }
return ""; return "";
@ -168,17 +253,24 @@ export default function Button({
<button <button
{...props} {...props}
className={twMerge( className={twMerge(
"bg-blue-600 text-white text-base font-medium px-4 py-2 rounded", "bg-primary text-white font-medium px-4 py-2 rounded-default",
"flex items-center justify-center relative transition-all", "flex items-center justify-center relative transition-all cursor-pointer",
props.disabled ? "opacity-40 cursor-not-allowed" : "",
"twui-button-general", "twui-button-general",
size == "small" && "px-3 py-1.5 text-sm", size == "small"
size == "smaller" && "px-2 py-1 text-xs", ? "px-3 py-1.5 text-sm twui-button-small"
size == "large" && "text-lg", : size == "smaller"
size == "larger" && "px-5 py-3 text-xl", ? "px-2 py-1 text-xs twui-button-smaller"
: size == "large"
? "text-lg twui-button-large"
: size == "larger"
? "px-5 py-3 text-xl twui-button-larger"
: "twui-button-base",
finalClassName, finalClassName,
props.className, loading ? "pointer-events-none opacity-80" : "",
loading ? "pointer-events-none opacity-80" : "l" props.className
)} )}
aria-label={props.title}
> >
<div <div
{...buttonContentProps} {...buttonContentProps}
@ -196,7 +288,6 @@ export default function Button({
{loading && ( {loading && (
<Loading <Loading
className="absolute"
size={(() => { size={(() => {
if (loadingIconSize) return loadingIconSize; if (loadingIconSize) return loadingIconSize;
switch (size) { switch (size) {
@ -209,6 +300,8 @@ export default function Button({
return "normal"; return "normal";
} }
})()} })()}
{...loadingProps}
className={twMerge("absolute", loadingProps?.className)}
/> />
)} )}
</button> </button>
@ -216,7 +309,13 @@ export default function Button({
if (href) if (href)
return ( return (
<a {...linkProps} href={href} target={target}> <a
{...linkProps}
href={href}
target={target}
title={props.title}
aria-label={props.title}
>
{buttonComponent} {buttonComponent}
</a> </a>
); );

View File

@ -13,7 +13,7 @@ export default function Center({
{...props} {...props}
className={twMerge( className={twMerge(
"flex flex-col items-center justify-center gap-4 p-2 w-full", "flex flex-col items-center justify-center gap-4 p-2 w-full",
"twui-center", "h-full twui-center",
props.className props.className
)} )}
> >

View File

@ -1,28 +1,32 @@
import { DetailedHTMLProps, HTMLAttributes } from "react"; import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
vertical?: boolean;
dashed?: boolean;
};
/** /**
* # Vertical and Horizontal Divider * # Vertical and Horizontal Divider
* @className twui-divider * @className twui-divider
* @className twui-divider-horizontal * @className twui-divider-horizontal
* @className twui-divider-vertical * @className twui-divider-vertical
*/ */
export default function Divider({ export default function Divider({ vertical, dashed, ...props }: Props) {
vertical,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
vertical?: boolean;
}) {
return ( return (
<div <div
{...props} {...props}
className={twMerge( className={twMerge(
"border-slate-200 dark:border-white/10 border-solid", "border-slate-200 dark:border-white/10",
vertical vertical
? "border-0 border-l h-full min-h-5" ? "border-0 border-l h-full min-h-5"
: "border-0 border-t w-full", : "border-0 border-t w-full",
"twui-divider", "twui-divider",
vertical ? "twui-divider-vertical" : "twui-divider-horizontal", vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
dashed ? "border-dashed" : "border-solid",
props.className props.className
)} )}
/> />

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 ( return (
<h1 <h1
{...props} {...props}
className={twMerge("text-5xl mb-4", "twui-h1", props.className)} className={twMerge(
"text-4xl md:text-5xl mb-4",
"twui-headings twui-heading",
"twui-h1",
props.className
)}
> >
{props.children} {props.children}
</h1> </h1>

View File

@ -11,7 +11,12 @@ export default function H2({
return ( return (
<h2 <h2
{...props} {...props}
className={twMerge("text-3xl mb-4", "twui-h2", props.className)} className={twMerge(
"text-2xl md:text-3xl mb-4",
"twui-headings twui-heading",
"twui-h2",
props.className
)}
> >
{props.children} {props.children}
</h2> </h2>

View File

@ -11,7 +11,12 @@ export default function H3({
return ( return (
<h3 <h3
{...props} {...props}
className={twMerge("text-xl mb-4", "twui-h3", props.className)} className={twMerge(
"text-xl mb-4",
"twui-headings twui-heading",
"twui-h3",
props.className
)}
> >
{props.children} {props.children}
</h3> </h3>

View File

@ -11,7 +11,12 @@ export default function H4({
return ( return (
<h4 <h4
{...props} {...props}
className={twMerge("text-base mb-4", "twui-h4", props.className)} className={twMerge(
"text-base mb-4",
"twui-headings twui-heading",
"twui-h4",
props.className
)}
> >
{props.children} {props.children}
</h4> </h4>

View File

@ -11,7 +11,12 @@ export default function H5({
return ( return (
<h5 <h5
{...props} {...props}
className={twMerge("text-sm mb-4", "twui-h5", props.className)} className={twMerge(
"text-sm mb-4",
"twui-headings twui-heading",
"twui-h5",
props.className
)}
> >
{props.children} {props.children}
</h5> </h5>

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>, ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement HTMLImageElement
> & { > & {
alt: string;
size?: number; size?: number;
circle?: boolean; circle?: boolean;
bgImg?: boolean; bgImg?: boolean;
@ -24,6 +25,8 @@ export default function Img({ ...props }: TWUIImageProps) {
const height = props.size || props.height; const height = props.size || props.height;
const sizeRatio = width && height ? Number(width) / Number(height) : 1; const sizeRatio = width && height ? Number(width) / Number(height) : 1;
const [imageError, setImageError] = React.useState(false);
const finalProps = _.omit(props, [ const finalProps = _.omit(props, [
"size", "size",
"circle", "circle",
@ -53,30 +56,70 @@ export default function Img({ ...props }: TWUIImageProps) {
} }
props.onError?.(e); props.onError?.(e);
}, },
style: {
...(props.size
? {
width: `${props.size}px`,
minWidth: `${props.size}px`,
height: `${props.size}px`,
}
: {}),
...props.style,
},
}; };
if (imageError) {
return (
<img
loading="lazy"
{...interpolatedProps}
src={
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
}
/>
);
}
if (props.srcDark && props.srcLight) { if (props.srcDark && props.srcLight) {
return ( return (
<React.Fragment> <React.Fragment>
<img <img
loading="lazy"
{...interpolatedProps} {...interpolatedProps}
className={twMerge( className={twMerge(
"hidden dark:block", "hidden dark:block",
interpolatedProps.className interpolatedProps.className
)} )}
src={props.srcDark} src={props.srcDark}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/> />
<img <img
loading="lazy"
{...interpolatedProps} {...interpolatedProps}
className={twMerge( className={twMerge(
"block dark:hidden", "block dark:hidden",
interpolatedProps.className interpolatedProps.className
)} )}
src={props.srcLight} src={props.srcLight}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/> />
</React.Fragment> </React.Fragment>
); );
} }
return <img {...interpolatedProps} />; return (
<img
{...interpolatedProps}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
);
} }

View File

@ -9,29 +9,41 @@ type Props = DetailedHTMLProps<
showArrow?: boolean; showArrow?: boolean;
arrowSize?: number; arrowSize?: number;
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>; arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
strict?: boolean;
}; };
/** /**
* # General Anchor Elements * # General Anchor Elements
* @className twui-a | twui-anchor * @className twui-a | twui-anchor
* @info use `cancel-link` class name to prevent triggering this link from a child element
*/ */
export default function Link({ export default function Link({
showArrow, showArrow,
arrowSize = 20, arrowSize = 20,
arrowProps, arrowProps,
strict,
...props ...props
}: Props) { }: Props) {
return ( return (
<a <a
{...props} {...props}
className={twMerge( className={twMerge(
"text-base text-link-500 no-underline hover:text-link-500/50", "text-link-500 no-underline hover:text-link-500/50",
"text-blue-600 dark:text-blue-400 hover:opacity-60 transition-all", "text-link dark:text-link-dark hover:opacity-80 transition-all",
"border-0 border-b border-blue-300 dark:border-blue-200/30 border-solid leading-4", "border-0 border-b border-link dark:border-link-dark border-solid leading-4",
"twui-anchor", "twui-anchor",
"twui-a", "twui-a",
props.className props.className
)} )}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest(".cancel-link")) {
e.preventDefault();
}
props?.onClick?.(e);
}}
data-strict={strict ? "yes" : undefined}
> >
{props.children} {props.children}
{showArrow && ( {showArrow && (

View File

@ -1,18 +1,24 @@
import { DetailedHTMLProps, HTMLAttributes } from "react"; import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
> & {
noMargin?: boolean;
};
/** /**
* # Paragraph Tag * # Paragraph Tag
* @className twui-p | twui-paragraph * @className twui-p | twui-paragraph
*/ */
export default function P({ export default function P({ noMargin, ...props }: Props) {
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return ( return (
<p <p
{...props} {...props}
className={twMerge( className={twMerge(
"text-base py-4", "py-4",
noMargin ? "!m-0 p-0" : "",
"twui-p", "twui-p",
"twui-paragraph", "twui-paragraph",
props.className props.className

View File

@ -1,18 +1,26 @@
import { DetailedHTMLProps, HTMLAttributes } from "react"; import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
noWrap?: boolean;
itemsStart?: boolean;
};
/** /**
* # Flexbox Row * # Flexbox Row
* @className twui-row * @className twui-row
*/ */
export default function Row({ export default function Row({ noWrap, itemsStart, ...props }: Props) {
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return ( return (
<div <div
{...props} {...props}
className={twMerge( className={twMerge(
"flex flex-row items-center gap-2 flex-wrap", "flex flex-row gap-2",
noWrap ? "xl:flex-nowrap" : "flex-wrap",
itemsStart ? "items-start" : "items-center",
"twui-row", "twui-row",
props.className props.className
)} )}

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 <span
{...props} {...props}
className={twMerge( className={twMerge(
"text-base", "",
size == "small" && "text-sm", size == "small" && "text-sm",
size == "smaller" && "text-xs", size == "smaller" && "text-xs",
size == "large" && "text-lg", size == "large" && "text-lg",

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 HTMLDivElement
> & { > & {
center?: boolean; center?: boolean;
gap?: number | string;
componentRef?: React.RefObject<HTMLDivElement>;
}; };
/** /**
* # Flexbox Column * # Flexbox Column
* @className twui-stack * @className twui-stack
*/ */
export default function Stack({ ...props }: Props) { export default function Stack({ gap, componentRef, ...props }: Props) {
const finalProps = _.omit(props, "center"); const finalProps = _.omit(props, "center");
return ( return (
<div <div
@ -21,9 +23,15 @@ export default function Stack({ ...props }: Props) {
className={twMerge( className={twMerge(
"flex flex-col items-start gap-4", "flex flex-col items-start gap-4",
props.center && "items-center", props.center && "items-center",
gap
? typeof gap == "string"
? `gap-[${gap}]`
: `gap-${gap}`
: "",
"twui-stack", "twui-stack",
props.className props.className
)} )}
ref={componentRef}
> >
{props.children} {props.children}
</div> </div>

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 H3 from "../../layout/H3";
import H4 from "../../layout/H4"; import H4 from "../../layout/H4";
import CodeBlock from "../../elements/CodeBlock"; import CodeBlock from "../../elements/CodeBlock";
import { twMerge } from "tailwind-merge";
type Params = { type Params = {
components: MDXComponents; components: MDXComponents;
@ -17,13 +18,12 @@ export default function useMDXComponents({
}: Params): MDXComponents { }: Params): MDXComponents {
return { return {
h1: ({ children }) => <H1>{children}</H1>, h1: ({ children }) => <H1>{children}</H1>,
h2: ({ children }) => <H2>{children}</H2>,
h3: ({ children }) => <H3>{children}</H3>,
h4: ({ children }) => <H4>{children}</H4>, h4: ({ children }) => <H4>{children}</H4>,
pre: ({ children, ...props }) => { pre: ({ children, ...props }) => {
if (React.isValidElement(children) && children.props) { if (React.isValidElement(children) && children.props) {
return ( return (
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}>
{/* @ts-ignore */}
{children.props.children} {children.props.children}
</CodeBlock> </CodeBlock>
); );

View File

@ -2,26 +2,30 @@
"name": "tailwind-ui", "name": "tailwind-ui",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "latest",
"lodash": "^4.17.21", "lodash": "latest",
"lucide-react": "^0.453.0", "lucide-react": "latest",
"react": "^19.0.0", "react-code-blocks": "latest",
"react-code-blocks": "^0.1.6", "react-responsive-modal": "latest",
"react-dom": "^19.0.0", "tailwind-merge": "latest",
"react-responsive-modal": "^6.4.2", "typescript": "latest",
"tailwind-merge": "^2.6.0", "mdx/types": "latest",
"typescript": "^5.7.3" "gray-matter": "latest",
"next-mdx-remote": "latest",
"remark-gfm": "latest",
"rehype-prism-plus": "latest",
"html-to-react": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ace": "^0.0.52", "@types/ace": "latest",
"@types/bun": "latest", "@types/bun": "latest",
"@types/lodash": "^4.17.15", "@types/lodash": "latest",
"@types/node": "^20.17.16", "@types/node": "latest",
"@types/react": "^18.3.18", "@types/react": "latest",
"@types/react-dom": "^18.3.5", "@types/react-dom": "latest",
"postcss": "^8.5.1", "postcss": "latest",
"tailwindcss": "^3.4.17", "tailwindcss": "^4",
"@types/mdx": "^2.0.13", "@types/mdx": "latest",
"@next/mdx": "^15.1.5" "@next/mdx": "latest"
} }
} }

68
components/lib/types.ts Normal file
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> { ): Promise<R> {
let data; let data;
const csrfValue = localStorage.getItem(localStorageCSRFKey || "csrf"); const csrfKey = "x-dsql-csrf-key";
const csrfValue = localStorage.getItem(localStorageCSRFKey || csrfKey);
let finalHeaders = { let finalHeaders = {
"Content-Type": "application/json", "Content-Type": "application/json",
} as FetchHeader; } as FetchHeader;
if (csrf && csrfValue) { if (csrf && csrfValue) {
finalHeaders[csrfHeaderKey || "x-csrf-key"] = csrfValue; finalHeaders[localStorageCSRFKey || csrfKey] = csrfValue;
} }
if (typeof options === "string") { if (typeof options === "string") {

View File

@ -2,36 +2,38 @@ export type ImageInputToBase64FunctionReturn = {
imageBase64?: string; imageBase64?: string;
imageBase64Full?: string; imageBase64Full?: string;
imageName?: string; imageName?: string;
imageType?: string;
}; };
export type ImageInputToBase64FunctioParam = { export type ImageInputToBase64FunctioParam = {
imageInput: HTMLInputElement; imageInput?: HTMLInputElement;
maxWidth?: number; maxWidth?: number;
mimeType?: string; mimeType?: string;
file?: File;
}; };
export default async function imageInputToBase64({ export default async function imageInputToBase64({
imageInput, imageInput,
maxWidth, maxWidth,
mimeType, mimeType,
file,
}: ImageInputToBase64FunctioParam): Promise<ImageInputToBase64FunctionReturn> { }: ImageInputToBase64FunctioParam): Promise<ImageInputToBase64FunctionReturn> {
try { try {
if (!imageInput.files?.[0]) { const finalFile = file || imageInput?.files?.[0];
throw new Error("No Files found in this image input");
if (!finalFile) {
throw new Error("No Files found");
} }
let imagePreviewNode = document.querySelector(
`[data-imagepreview='image']` let imageName = finalFile.name.replace(/\..*/, "");
);
let imageName = imageInput.files[0].name.replace(/\..*/, "");
let imageDataBase64: string | undefined; let imageDataBase64: string | undefined;
const MIME_TYPE = mimeType ? mimeType : "image/jpeg"; const MIME_TYPE = mimeType ? mimeType : finalFile.type;
const QUALITY = 0.95; const QUALITY = 0.95;
const MAX_WIDTH = maxWidth ? maxWidth : null; const MAX_WIDTH = maxWidth ? maxWidth : null;
const file = imageInput.files[0]; const blobURL = URL.createObjectURL(finalFile);
const blobURL = URL.createObjectURL(file);
const img = new Image(); const img = new Image();
img.src = blobURL; img.src = blobURL;
@ -76,6 +78,7 @@ export default async function imageInputToBase64({
imageBase64: imageDataBase64?.replace(/.*?base64,/, ""), imageBase64: imageDataBase64?.replace(/.*?base64,/, ""),
imageBase64Full: imageDataBase64, imageBase64Full: imageDataBase64,
imageName: imageName, imageName: imageName,
imageType: MIME_TYPE,
}; };
} catch (error: any) { } catch (error: any) {
console.log("Image Processing Error! =>", error.message); console.log("Image Processing Error! =>", error.message);
@ -87,7 +90,3 @@ export default async function imageInputToBase64({
}; };
} }
} }
/** ********************************************** */
/** ********************************************** */
/** ********************************************** */

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 = { const nextConfig: NextConfig = {
/* config options here */ reactStrictMode: true,
reactStrictMode: true, eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
}; };
export default nextConfig; const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
});
export default withMDX(nextConfig);

View File

@ -10,22 +10,28 @@
}, },
"dependencies": { "dependencies": {
"@moduletrace/buncid": "^1.0.7", "@moduletrace/buncid": "^1.0.7",
"@moduletrace/datasquirel": "^2.7.4", "@moduletrace/datasquirel": "^5.1.0",
"@moduletrace/twui": "file:./components/lib", "@moduletrace/twui": "file:./components/lib",
"gray-matter": "^4.0.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next": "15.0.3", "next": "15.0.3",
"next-mdx-remote": "^5.0.0",
"prism-themes": "^1.9.0",
"react": "19.0.0-rc-66855b96-20241106", "react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106",
"rehype-prism-plus": "^2.0.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.5" "tailwind-merge": "^2.5.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -1,6 +1,25 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import { PagePropsType } from "@/types";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import "prism-themes/themes/prism-dracula.css";
import React from "react";
export type AppContextType = {
pageProps: PagePropsType;
};
export const AppContext = React.createContext<AppContextType>({
pageProps: {},
});
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />; return (
<AppContext.Provider
value={{
pageProps,
}}
>
<Component {...pageProps} />
</AppContext.Provider>
);
} }

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 = { const config = {
plugins: { plugins: ["@tailwindcss/postcss"],
tailwindcss: {},
},
}; };
export default config; export default config;

View File

@ -1,14 +1,11 @@
@tailwind base; @import "../components/lib/base.css";
@tailwind components;
@tailwind utilities;
:root { :root {
--bg-color: #02030f;
--header-height: 78px; --header-height: 78px;
} }
body { @theme inline {
@apply bg-[var(--bg-color)] text-white; --color-primary: #02030f;
} }
.twui-button-general { .twui-button-general {
@ -57,3 +54,7 @@ body {
.twui-button-primary-ghost { .twui-button-primary-ghost {
@apply bg-transparent text-white; @apply bg-transparent text-white;
} }
.twui-card-link {
@apply p-0 w-full border-none;
}

22
types.ts Normal file
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;
};