Package library as @moduletrace/twui npm package

- Move components/ to src/ and compile to dist/ with tsc
- Add root barrel export plus deep-import subpath exports and base.css export
- Post-compile step: copy base.css, add .js import extensions, mark client modules
- Add publish.sh and .npmrc for the Gitea npm registry
- Fix type errors, guard TinyMCE env access, drop ace-builds type dependency
- Rename clashing props type names, remove Next.js scaffolding and unused deps

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 10:17:54 +01:00
co-authored by Claude Opus 5.5
parent d19d412066
commit 11da9b89e7
148 changed files with 937 additions and 500 deletions
@@ -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;
}
+72
View File
@@ -0,0 +1,72 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import ReactDOM from "react-dom";
import Button from "../layout/Button";
import { X } from "lucide-react";
import { type 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,
no_cancel_button,
...props
}: Props) {
if (!open) return null;
return ReactDOM.createPortal(
<div
className={twMerge(
"fixed z-200 top-0 left-0 w-screen h-screen",
"flex flex-col items-center justify-center p-4",
"twui-modal-root",
)}
role="dialog"
aria-modal="true"
>
<div
className={twMerge(
"absolute top-0 left-0 bg-dark/80 z-0",
"w-screen h-screen",
)}
onClick={(e) => {
setOpen(false);
}}
></div>
<Paper
{..._.omit(props, ["targetWrapperProps"])}
className={twMerge(
"z-10 max-w-modal bg-background-light dark:bg-background-dark",
"w-full relative max-h-[95vh] overflow-y-auto",
"twui-modal-content",
props.className,
)}
>
{props.children}
{!no_cancel_button && (
<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,
);
}
+116
View File
@@ -0,0 +1,116 @@
import React from "react";
import { twMerge } from "tailwind-merge";
import ReactDOM from "react-dom";
import type { 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]);
const closeTimeoutRef = React.useRef<any>(null);
const popoverEnterFn = React.useCallback(() => {
popoverContentActiveRef.current = true;
popoverTargetActiveRef.current = false;
window.clearTimeout(closeTimeoutRef.current);
setOpen(true);
}, []);
const popoverLeaveFn = React.useCallback(() => {
window.clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => {
if (popoverTargetActiveRef.current) {
popoverTargetActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
}, []);
if (!open) return null;
return ReactDOM.createPortal(
<Paper
{...props}
className={twMerge(
"max-w-[300px] z-[250]",
"twui-popover-content",
props.className,
)}
style={{ ...style, ...props.style }}
onMouseEnter={
trigger === "hover" ? popoverEnterFn : props.onMouseEnter
}
onMouseLeave={
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
}
role="dialog"
aria-modal="true"
>
{/* <div
className="absolute w-0 h-0 border-8 border-transparent bg-white"
style={{
...(position === "bottom" && {
top: "-16px",
left: "50%",
transform: "translateX(-50%)",
}),
...(position === "top" && {
bottom: "-16px",
left: "50%",
transform: "translateX(-50%)",
}),
...(position === "right" && {
top: "50%",
left: "-16px",
transform: "translateY(-50%)",
}),
...(position === "left" && {
top: "50%",
right: "-16px",
transform: "translateY(-50%)",
}),
}}
/> */}
{props.children}
</Paper>,
document.getElementById("twui-popover-root") as HTMLElement,
);
}
+192
View File
@@ -0,0 +1,192 @@
@import "tailwindcss";
/* Lets Tailwind generate the classes used by the compiled components */
@source "./";
@theme inline {
--breakpoint-xs: 350px;
--breakpoint-xxs: 300px;
--breakpoint-xxl: 1600px;
--color-background-light: #ffffff;
--color-foreground-light: #171717;
--color-background-dark: #0a0a0a;
--color-foreground-dark: #ededed;
--color-dark: #000000;
--color-primary: #000000;
--color-primary-hover: #29292b;
--color-primary-outline: #29292b;
--color-primary-text: #29292b;
--color-primary-dark: #29292b;
--color-primary-dark-hover: #4b4b4b;
--color-primary-dark-outline: #4b4b4b;
--color-primary-dark-text: #ffffff;
--color-secondary: #2b4bf0;
--color-secondary-hover: #2b4bf0;
--color-secondary-outline: #2b4bf0;
--color-secondary-text: #2b4bf0;
--color-secondary-dark: #2b4bf0;
--color-secondary-dark-hover: #2b4bf0;
--color-secondary-dark-outline: #2b4bf0;
--color-secondary-dark-text: #ffffff;
--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;
/* Status colors. `*-dark` is the bright tone for text and outlines on
dark pages; filled dark-mode buttons/tags use the deeper `*-dark-fill`
with `*-dark-text` ink so white text keeps its contrast */
--color-success: #0aa156;
--color-success-hover: #088a4a;
--color-success-dark: #2ed984;
--color-success-dark-fill: #087443;
--color-success-dark-fill-hover: #0a8a50;
--color-success-dark-text: #ffffff;
--color-error: #e5484d;
--color-error-hover: #d13c41;
--color-error-dark: #ff6b70;
--color-error-dark-fill: #c4282e;
--color-error-dark-fill-hover: #d6353b;
--color-error-dark-text: #ffffff;
--color-warning: #ff6900;
--color-warning-hover: #e65f00;
--color-warning-dark: #ff8c3a;
--color-warning-dark-fill: #c2410c;
--color-warning-dark-fill-hover: #d4500f;
--color-warning-dark-text: #ffffff;
--color-link: #0051c9;
--color-link-dark: #548adb;
--radius-default: 5px;
--radius-default-sm: 3px;
--radius-default-xs: 1px;
--radius-default-lg: 7px;
--radius-default-xl: 10px;
--container-container: 1200px;
--container-modal: 800px;
}
@custom-variant dark (&:where(.dark, .dark *));
body {
@apply bg-background-light dark:bg-background-dark;
@apply text-foreground-light dark:text-foreground-dark;
font-family: Arial, Helvetica, sans-serif;
}
.tox-tinymce {
@apply w-full !rounded-default !border-slate-300 dark:!border-white/20;
}
/* .moving-object {
@apply !bg-green-500;
} */
option {
@apply dark:bg-background-dark;
}
.mobile-paper-hidden {
@apply max-md:p-0 max-md:border-none max-md:bg-transparent;
}
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-gray rounded-full dark:bg-gray;
}
::-webkit-scrollbar-thumb {
@apply bg-foreground-light/40 rounded-full hover:bg-foreground-light/60;
@apply dark:bg-foreground-dark/40 rounded-full hover:bg-foreground-dark/60;
}
* {
scrollbar-width: thin;
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
}
@supports (selector(:where(*))) {
:where(*) {
scrollbar-width: thin;
scrollbar-color: theme("colors.gray.400") theme("colors.gray.100");
}
.dark :where(*) {
scrollbar-color: theme("colors.gray.500") theme("colors.gray.800");
}
}
.ace_editor {
@apply dark:bg-background-dark;
}
.tox-editor-header,
.tox-toolbar-overlord,
.tox .tox-toolbar,
.tox .tox-toolbar__overflow,
.tox .tox-toolbar__primary,
.tox .tox-tbtn,
.tox .tox-sidebar,
.tox .tox-statusbar,
.tox .tox-view-wrap,
.tox .tox-view-wrap__slot-container,
.tox .tox-editor-container,
.tox .tox-edit-area__iframe,
.twui-tinymce {
@apply dark:!bg-background-dark;
}
.twui-tinymce *:focus {
@apply !outline-white/10;
}
.tox .tox-tbtn:hover {
@apply dark:!bg-white/10;
}
.ace_gutter {
@apply dark:!bg-background-dark;
}
.ace_active-line,
.ace_gutter-active-line {
@apply dark:!bg-white/5;
}
.normal-text {
@apply text-foreground-light dark:text-foreground-dark;
}
ol {
list-style: decimal;
}
ul {
list-style: disc;
}
ul,
ol {
margin-left: 25px;
}
+41
View File
@@ -0,0 +1,41 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import type { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import TWUIDocsLink from "./TWUIDocsLink";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
DocsLinks: DocsLinkType[];
before?: React.ReactNode;
after?: React.ReactNode;
autoExpandAll?: boolean;
};
export default function TWUIDocsAside({
DocsLinks,
after,
before,
autoExpandAll,
...props
}: Props) {
return (
<aside
{...props}
className={twMerge(
"pb-10 hidden xl:flex sticky top-6",
props.className,
)}
>
<Stack>
{before}
{DocsLinks.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
autoExpandAll={autoExpandAll}
/>
))}
{after}
</Stack>
</aside>
);
}
+128
View File
@@ -0,0 +1,128 @@
import React, {
type AnchorHTMLAttributes,
type ComponentProps,
type DetailedHTMLProps,
} from "react";
import type { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import { twMerge } from "tailwind-merge";
import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import { ChevronDown, Circle } from "lucide-react";
import Button from "../../layout/Button";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
docLink: DocsLinkType;
wrapperProps?: ComponentProps<typeof Stack>;
strict?: boolean;
childWrapperProps?: ComponentProps<typeof Stack>;
autoExpandAll?: boolean;
child?: boolean;
};
/**
* # TWUI Docs Left Aside Link
* @note use dataset attribute `data-strict` for strict matching
*
* @className `twui-docs-left-aside-link`
*/
export default function TWUIDocsLink({
docLink,
wrapperProps,
childWrapperProps,
strict,
autoExpandAll,
child,
...props
}: Props) {
const [isActive, setIsActive] = React.useState(false);
const [expand, setExpand] = React.useState(autoExpandAll || false);
const linkRef = React.useRef<HTMLAnchorElement>(null);
React.useEffect(() => {
if (typeof window !== "undefined") {
const basePathMatch = window.location.pathname.includes(
docLink.href,
);
const isStrictMatch = Boolean(
linkRef.current?.getAttribute("data-strict"),
);
if (strict || isStrictMatch) {
setIsActive(window.location.pathname === docLink.href);
} else {
setIsActive(basePathMatch);
}
if (basePathMatch) {
setExpand(true);
}
}
}, []);
return (
<Stack
className={twMerge("gap-2 w-full", wrapperProps?.className)}
{...wrapperProps}
>
<Row className="flex-nowrap grow justify-between w-full">
{child && <Circle size={6} />}
<a
href={docLink.href}
title={docLink.title}
{...props}
className={twMerge(
"twui-docs-left-aside-link whitespace-nowrap",
"grow overflow-hidden overflow-ellipsis",
isActive ? "active" : "",
props.className,
)}
ref={linkRef}
data-strict={strict || docLink.strict}
>
{docLink.title}
</a>
{docLink.children?.[0] && (
<Button
variant="ghost"
color="gray"
className={twMerge(
"p-1 hover:opacity-100",
expand ? "rotate-180 opacity-30" : "opacity-70",
)}
onClick={() => setExpand(!expand)}
title="Docs Aside Links Dropdown Button"
>
<ChevronDown className="text-slate-500" size={20} />
</Button>
)}
</Row>
{docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Stack
className={twMerge(
"gap-2 w-full pl-3",
childWrapperProps?.className,
)}
{...childWrapperProps}
>
{docLink.children.map((link, index) => (
<TWUIDocsLink
key={index}
docLink={link}
className="opacity-70"
autoExpandAll={autoExpandAll}
child
/>
))}
</Stack>
</Row>
)}
</Stack>
);
}
+177
View File
@@ -0,0 +1,177 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import type { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import TWUIDocsLink from "./TWUIDocsLink";
import { twMerge } from "tailwind-merge";
import Span from "../../layout/Span";
import Row from "../../layout/Row";
import { ArrowUpRight, LinkIcon, ListIcon } from "lucide-react";
import Link from "../../layout/Link";
type Props = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
before?: React.ReactNode;
after?: React.ReactNode;
autoExpandAll?: boolean;
editPageURL?: string;
};
export default function TWUIDocsRightAside({
after,
before,
autoExpandAll,
editPageURL,
...props
}: Props) {
const [links, setLinks] = React.useState<DocsLinkType[]>([]);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!ready) return;
const headerHrefs = document.querySelectorAll(
".twui-docs-header-anchor",
);
const linksArr: DocsLinkType[] = [];
for (let i = 0; i < headerHrefs.length; i++) {
const anchorEl = headerHrefs[i] as HTMLAnchorElement;
const isH2Element = anchorEl.querySelector("h2") !== null;
if (isH2Element) {
let newLink: DocsLinkType = {
title: anchorEl.textContent || "",
href: `#${anchorEl.id}`,
};
let nexElIndex = i + 1;
while (nexElIndex < headerHrefs.length) {
const nextElement = headerHrefs[
nexElIndex
] as HTMLAnchorElement;
const nextElementH3 = nextElement.querySelector("h3");
const isNextElementH2 =
nextElement.querySelector("h2") !== null;
if (isNextElementH2) {
break;
}
if (!nextElementH3) {
break;
}
if (!newLink.children) {
newLink.children = [];
}
newLink.children.push({
title: nextElementH3.textContent || "",
href: `#${nextElement.id}`,
});
nexElIndex++;
}
linksArr.push(newLink);
}
}
setLinks(linksArr);
}, [ready]);
React.useEffect(() => {
if (!links.length) return;
const headerHrefs = document.querySelectorAll(
"a.twui-docs-header-anchor",
);
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
const link = document.querySelector(
`.twui-docs-right-aside a[href="#${id}"]`,
);
if (link) {
link.classList.add("active");
}
} else {
const id = entry.target.id;
const link = document.querySelector(
`.twui-docs-right-aside a[href="#${id}"]`,
);
if (link) {
link.classList.remove("active");
}
}
});
});
headerHrefs.forEach((headerHref) => {
observer.observe(headerHref);
});
}, [links]);
React.useEffect(() => {
setTimeout(() => {
setReady(true);
}, 100);
}, []);
return (
<aside
{...props}
className={twMerge(
"pb-10 hidden xl:flex min-w-[150px] max-w-[200px]",
"sticky top-6 twui-docs-right-aside",
props.className,
)}
>
<Stack className="w-full overflow-hidden">
{before}
<Stack className="w-full">
<Row>
<ListIcon size={12} opacity={0.5} />
<Span size="smaller" variant="faded">
On this page
</Span>
</Row>
{links.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
autoExpandAll
childWrapperProps={{
className: "pl-2",
}}
wrapperProps={{
className:
"[&_svg]:hidden [&_a]:text-xs [&_a]:overflow-hidden [&_a]:overflow-ellipsis",
}}
/>
))}
</Stack>
{after}
{editPageURL && (
<Link
href={editPageURL}
target="_blank"
className="text-[12px] mt-2"
>
<Row className="gap-2">
<LinkIcon size={12} opacity={0.5} />
<span>Edit This Page</span>
<ArrowUpRight size={15} className="-ml-1" />
</Row>
</Link>
)}
</Stack>
</aside>
);
}
+88
View File
@@ -0,0 +1,88 @@
import type {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import Stack from "../../layout/Stack";
import Container from "../../layout/Container";
import Row from "../../layout/Row";
import TWUIDocsAside from "./TWUIDocsAside";
import { twMerge } from "tailwind-merge";
import Paper from "../../elements/Paper";
import TWUIDocsRightAside from "./TWUIDocsRightAside";
export type DocsLinkType = {
title: string;
href: string;
strict?: boolean;
children?: DocsLinkType[];
editPage?: string;
};
type Props = PropsWithChildren & {
DocsLinks: DocsLinkType[];
docsAsideBefore?: React.ReactNode;
docsAsideAfter?: React.ReactNode;
wrapperProps?: ComponentProps<typeof Stack>;
docsContentProps?: ComponentProps<typeof Row>;
leftAsideProps?: DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
>;
autoExpandAll?: boolean;
editPageURL?: string;
};
/**
* # TWUI Docs
* @className `twui-docs-content`
*/
export default function TWUIDocs({
children,
DocsLinks,
docsAsideAfter,
docsAsideBefore,
wrapperProps,
docsContentProps,
leftAsideProps,
autoExpandAll,
editPageURL,
}: Props) {
return (
<Stack
center
{...wrapperProps}
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
>
<Container>
<Paper className="xl:p-8 mobile-paper-hidden">
<Row
{...docsContentProps}
className={twMerge(
"items-start gap-8 w-full flex-nowrap",
docsContentProps?.className,
)}
>
<TWUIDocsAside
DocsLinks={DocsLinks}
after={docsAsideAfter}
before={docsAsideBefore}
autoExpandAll={autoExpandAll}
{...leftAsideProps}
/>
<div
className={twMerge(
"block twui-docs-content pl-0 xl:pl-6 grow",
"overflow-hidden",
)}
>
{children}
</div>
<TWUIDocsRightAside editPageURL={editPageURL} />
</Row>
</Paper>
</Container>
</Stack>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React, { type RefObject } from "react";
import { twMerge } from "tailwind-merge";
import AceEditorModes from "./ace-editor-modes";
import type { Ace as AceAjax } from "ace-builds";
export type AceEditorComponentType = {
editorRef?: RefObject<AceAjax.Editor | undefined>;
readOnly?: boolean;
/** Function to call when Ctrl+Enter is pressed */
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
content?: string;
placeholder?: string;
title?: string;
mode?: (typeof AceEditorModes)[number];
fontSize?: string;
previewMode?: boolean;
onChange?: (value: string, editor?: AceAjax.Editor) => void;
delay?: number;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
editorOptions?: AceAjax.EditorOptions;
showLabel?: boolean;
};
let timeout: any;
/**
* # Powerful Ace Editor
* @note **NOTE** head scripts required
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js`
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js`
*/
export default function AceEditor({
editorRef,
readOnly,
ctrlEnterFn,
content = "",
placeholder,
mode,
fontSize,
previewMode,
onChange,
delay = 500,
wrapperProps,
editorOptions,
showLabel,
title,
}: AceEditorComponentType) {
try {
const editorElementRef = React.useRef<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!ready) return;
// @ts-ignore
const ace = window.ace;
if (!ace?.edit || !editorElementRef.current) {
setTimeout(() => {
setRefresh((prev) => prev + 1);
}, 1000);
return;
}
// @ts-ignore
const editor = ace.edit(editorElementRef.current) as AceAjax.Editor;
editor.setOptions({
mode: `ace/mode/${mode ? mode : "javascript"}`,
theme: darkMode
? "ace/theme/tomorrow_night_eighties"
: "ace/theme/ace_light",
value: (() => {
try {
return JSON.stringify(JSON.parse(content), null, 4);
} catch (error) {
return content;
}
})(),
placeholder: placeholder ? placeholder : "",
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
readOnly: readOnly ? true : false,
fontSize: fontSize ? fontSize : undefined,
showLineNumbers: previewMode ? false : true,
wrap: true,
wrapMethod: "code",
...editorOptions,
});
editor.commands.addCommand({
name: "myCommand",
bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" },
exec: function (editor) {
if (ctrlEnterFn) ctrlEnterFn(editor);
},
readOnly: true,
});
editor.getSession().on("change", function (e) {
if (onChange) {
clearTimeout(timeout);
setTimeout(() => {
try {
onChange(
editor.getValue(),
editorRefInstance.current,
);
} catch (error) {}
}, delay);
}
});
editorRefInstance.current = editor;
if (editorRef) editorRef.current = editor;
return function () {
editor.destroy();
};
}, [refresh, darkMode, ready, mode]);
React.useEffect(() => {
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setReady(true);
}, 200);
}, []);
return (
<React.Fragment>
<div
{...wrapperProps}
className={twMerge(
"w-full h-[400px] block rounded-default",
"border border-border border-solid relative",
"dark:border-white/20",
showLabel && title ? "pt-4" : "",
wrapperProps?.className,
)}
>
{showLabel && title ? (
<label
className={twMerge(
"bg-background-light dark:bg-background-dark text-xs",
"-top-3 left-2 px-2 py-1 absolute z-10",
)}
>
{title}
</label>
) : null}
<div
ref={editorElementRef as any}
className="w-full h-full"
></div>
</div>
</React.Fragment>
);
} catch (error: any) {
return (
<React.Fragment>
<span className="m-0">
Editor Error: <b className="text-error">{error.message}</b>
</span>
</React.Fragment>
);
}
}
+22
View File
@@ -0,0 +1,22 @@
const FALLBACK_BASE_URL = "https://www.datasquirel.com/tinymce-public";
/**
* Resolves the TinyMCE base URL from public env vars. Each lookup is wrapped
* in try/catch rather than a `typeof process` check: bundlers inline the full
* `process.env.X` expression, so the global `process` may still be undefined
* in the browser even when the value is available.
*/
export default function getTinyMCEDefaultBaseUrl(): string {
let nextUrl: string | undefined;
let bunextUrl: string | undefined;
try {
nextUrl = process.env.NEXT_PUBLIC_TINYMCE_BASE_URL;
} catch {}
try {
bunextUrl = process.env.BUNEXT_PUBLIC_TINYMCE_BASE_URL;
} catch {}
return nextUrl || bunextUrl || FALLBACK_BASE_URL;
}
+242
View File
@@ -0,0 +1,242 @@
import React, { useCallback, type ComponentProps, type RefObject } from "react";
import { type Editor, type RawEditorOptions } from "./tinymce";
import { twMerge } from "tailwind-merge";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import Border from "../../elements/Border";
import useTinyMCE from "./useTinyMCE";
import getTinyMCEDefaultBaseUrl from "./default-base-url";
export type TinyMCEEditorProps<KeyType extends string> = {
options?: RawEditorOptions;
editorRef?: React.RefObject<Editor | null>;
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
borderProps?: ComponentProps<typeof Border>;
defaultValue?: string;
name?: KeyType;
changeHandler?: (content: string, editor?: Editor) => void;
showLabel?: boolean;
useParentCSS?: boolean;
placeholder?: string;
refreshDependencyArray?: any[];
base_url?: string;
};
/**
* # Tiny MCE Editor Component
* @className_wrapper twui-rte-wrapper
*/
export default function TinyMCEEditor<KeyType extends string>({
options,
editorRef: passedEditorRef,
setEditor: passedSetEditor,
wrapperProps,
defaultValue,
changeHandler,
wrapperWrapperProps,
borderProps,
name,
showLabel,
useParentCSS,
placeholder,
refreshDependencyArray,
base_url,
}: TinyMCEEditorProps<KeyType>) {
const { tinyMCE } = useTinyMCE({ base_url });
const editorComponentRef = React.useRef<HTMLDivElement>(null);
const editorRef: RefObject<Editor | null> =
passedEditorRef || React.useRef(null);
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
const FINAL_HEIGHT = options?.height || 500;
const [themeReady, setThemeReady] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [darkMode, setDarkMode] = React.useState(false);
const [refresh, setRefresh] = React.useState(0);
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
React.useEffect(() => {
if (!tinyMCE) {
return;
}
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setThemeReady(true);
}, 200);
}, [tinyMCE]);
let valueTimeout: any;
const id = crypto.randomUUID();
const updateContent = useCallback((editor: Editor) => {
if (changeHandler) {
window.clearTimeout(valueTimeout);
valueTimeout = setTimeout(() => {
changeHandler(
editor.getContent(),
editorRef.current || undefined,
);
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}
}, []);
React.useEffect(() => {
if (!editorComponentRef.current || !themeReady || !tinyMCE) {
return;
}
const baseUrl =
base_url ||
getTinyMCEDefaultBaseUrl();
tinyMCE.init({
height: FINAL_HEIGHT,
menubar: false,
plugins:
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
toolbar:
"undo redo | blocks | bold italic underline link image | align bullist numlist | outdent indent removeformat code searchreplace wordcount preview insertdatetime",
content_style: [
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
`.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { color: ${
darkMode
? "rgba(255, 255, 255, 0.45)"
: "rgba(34, 47, 62, 0.6)"
} !important; }`,
].join("\n"),
init_instance_callback: (editor) => {
if (editorRef) {
editorRef.current = editor;
passedSetEditor?.(editor);
}
if (defaultValue) editor.setContent(defaultValue);
setReady(true);
editor.on("change", (e) => {
updateContent(editor);
});
editor.on("input", (e) => {
updateContent(editor);
});
if (useParentCSS) {
useParentStyles(editor);
}
},
base_url: baseUrl,
body_class: "twui-tinymce",
placeholder,
relative_urls: true,
remove_script_host: true,
convert_urls: false,
...options,
license_key: "gpl",
target: editorComponentRef.current,
content_css: darkMode ? "dark" : undefined,
skin: darkMode ? "oxide-dark" : undefined,
});
return function () {
if (!ready) return;
const instance = editorComponentRef.current
? tinyMCE?.get(editorComponentRef.current?.id)
: undefined;
instance?.remove();
};
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
return (
<div
{...wrapperWrapperProps}
className={twMerge(
"relative w-full [&_.tox-tinymce]:!border-none",
"bg-background-light dark:bg-background-dark",
wrapperWrapperProps?.className,
)}
>
{showLabel && (
<label
className={twMerge(
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
"bg-background-light dark:bg-background-dark text-gray-500",
"dark:text-white/80 rounded",
)}
htmlFor={id}
>
{title}
</label>
)}
<Border
{...borderProps}
className={twMerge(
"dark:border-white/30 p-0 pt-2",
borderProps?.className,
)}
>
<div
{...wrapperProps}
ref={editorComponentRef}
style={{
height:
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
...wrapperProps?.style,
}}
className={twMerge(
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
"twui-rte-wrapper",
)}
id={id}
></div>
</Border>
</div>
);
}
function useParentStyles(editor: Editor) {
const doc = editor.getDoc();
const parentStylesheets = document.styleSheets;
for (const sheet of parentStylesheets) {
try {
if (sheet.href) {
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = sheet.href;
doc.head.appendChild(link);
} else {
const rules = sheet.cssRules || sheet.rules;
if (rules) {
const style = doc.createElement("style");
for (const rule of rules) {
try {
style.appendChild(doc.createTextNode(rule.cssText));
} catch (e) {
console.warn("Could not copy CSS rule:", rule, e);
}
}
doc.head.appendChild(style);
}
}
} catch (e) {
console.warn("Error processing stylesheet:", sheet, e);
}
}
}
+3313
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import React from "react";
import type { TinyMCE } from "./tinymce";
import getTinyMCEDefaultBaseUrl from "./default-base-url";
let interval: any;
type Params = {
base_url?: string;
};
export default function useTinyMCE(params?: Params) {
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE>();
const [refresh, setRefresh] = React.useState(0);
const [scriptLoaded, setScriptLoaded] = React.useState(false);
React.useEffect(() => {
if (refresh >= 5) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
if (clientWindow.tinymce) {
setScriptLoaded(true);
return;
}
const script = document.createElement("script");
const baseUrl =
params?.base_url ||
getTinyMCEDefaultBaseUrl();
script.src = `${baseUrl}/tinymce.min.js`;
script.async = true;
script.onload = () => {
setScriptLoaded(true);
};
document.head.appendChild(script);
}, [refresh]);
React.useEffect(() => {
if (!scriptLoaded) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
let tinyMCE = clientWindow.tinymce;
if (tinyMCE) {
setTinyMCE(tinyMCE);
} else {
setRefresh((prev) => prev + 1);
}
}, [scriptLoaded]);
return { tinyMCE };
}
+125
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;
+42
View File
@@ -0,0 +1,42 @@
import type { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
componentRef?: RefObject<HTMLDivElement>;
};
/**
* # Toggle Component
* @className_wrapper twui-border
*/
export default function Border({
spacing,
componentRef,
...props
}: TWUI_BORDER_PROPS) {
return (
<div
{...props}
className={twMerge(
"relative flex items-center gap-2 border border-solid rounded-default",
"border-slate-200 dark:border-white/10",
spacing
? spacing == "normal"
? "px-3 py-2"
: spacing == "tight"
? "px-2 py-1"
: ""
: "px-3 py-2",
"twui-border",
props.className,
)}
ref={componentRef}
>
{props.children}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
import React, { type ComponentProps, type ReactNode } from "react";
import Link from "../layout/Link";
import Divider from "../layout/Divider";
import Row from "../layout/Row";
import lowerToTitleCase from "../utils/lower-to-title-case";
import { twMerge } from "tailwind-merge";
import { ChevronLeft } from "lucide-react";
import Button from "../layout/Button";
type LinkObject = {
title: string;
path: string;
};
type Props = {
excludeRegexMatch?: RegExp;
linkProps?: ComponentProps<typeof Link>;
currentLinkProps?: ComponentProps<typeof Link>;
dividerProps?: ComponentProps<typeof Divider>;
backButtonProps?: ComponentProps<typeof Button>;
backButton?: boolean;
pageUrl?: string;
currentTitle?: string;
skipHome?: boolean;
divider?: ReactNode;
};
/**
* # TWUI Breadcrumbs
* @className `twui-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
* @className `twui-breadcrumbs-divider`
* @className `twui-breadcrumbs-back-button`
*/
export default function Breadcrumbs({
excludeRegexMatch,
linkProps,
currentLinkProps,
dividerProps,
backButton,
backButtonProps,
pageUrl,
currentTitle,
skipHome,
divider,
}: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(
pageUrl
? twuiBreadcrumbsGenerateLinksFromUrl({ url: pageUrl, skipHome })
: null,
);
React.useEffect(() => {
if (links) return;
let pathname = window.location.pathname;
let validPathLinks = twuiBreadcrumbsGenerateLinksFromUrl({
url: pathname,
excludeRegexMatch,
skipHome,
});
setLinks(validPathLinks);
return function () {
setLinks(null);
};
}, []);
if (!links?.[1]) {
return <React.Fragment></React.Fragment>;
}
return (
<nav
className={twMerge(
"overflow-x-auto",
"twui-current-breadcrumb-wrapper",
)}
aria-label="Breadcrumb"
>
<Row
className={twMerge(
"gap-4 flex-nowrap whitespace-nowrap overflow-x-auto overflow-y-hidden w-full",
)}
>
{backButton && (
<React.Fragment>
<Button
variant="ghost"
color="gray"
{...backButtonProps}
className={twMerge(
"p-1 -my-2 -mx-2",
"twui-breadcrumbs-back-button",
backButtonProps?.className,
)}
onClick={(e) => {
window.history.back();
backButtonProps?.onClick?.(e);
}}
title="Breadcrumbs Back Button"
beforeIcon={<ChevronLeft size={20} />}
/>
{divider || (
<Divider
vertical
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
)}
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-primary-text/50 dark:text-primary-dark-text/50 text-xs",
"max-w-[200px] text-ellipsis overflow-hidden",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
title={
currentLinkProps?.title || linkObject.title
}
>
{currentTitle || linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
{...linkProps}
{...(isTarget ? currentLinkProps : {})}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-breadcrumb-link",
linkProps?.className,
isTarget && currentLinkProps?.className,
)}
>
{currentLinkProps?.title ||
linkObject.title}
</Link>
{divider || (
<Divider
vertical
{...dividerProps}
className={twMerge(
"twui-breadcrumbs-divider",
dividerProps?.className,
)}
/>
)}
</React.Fragment>
);
}
})}
</Row>
</nav>
);
////////////////////////////////////////
////////////////////////////////////////
////////////////////////////////////////
}
export function twuiBreadcrumbsGenerateLinksFromUrl({
url,
excludeRegexMatch,
skipHome,
}: {
url: string;
excludeRegexMatch?: RegExp;
skipHome?: boolean;
}) {
let pathLinks = url.split("/");
let validPathLinks = [];
if (!skipHome) {
validPathLinks.push({
title: "Home",
path: url.match(/admin/) ? "/admin" : "/",
});
}
pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./)) {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
let path = "";
for (let i = 0; i < array.length; i++) {
const lnText = array[i];
if (i > index || !lnText?.match(/./)) continue;
path += `/${lnText}`;
}
return path;
})(),
});
});
return validPathLinks;
}
+72
View File
@@ -0,0 +1,72 @@
import React, {
type ComponentProps,
type DetailedHTMLProps,
type HTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
variant?: "normal";
href?: string;
linkProps?: ComponentProps<typeof Link>;
noHover?: boolean;
elRef?: React.RefObject<HTMLDivElement>;
linkRef?: React.RefObject<HTMLAnchorElement>;
};
/**
* # General Card
* @className twui-card
* @className twui-card-link
*
* @info use the classname `nested-link` to prevent the card from being clickable when
* a link (or the target element with this calss) inside the card is clicked.
*/
export default function Card({
href,
variant,
linkProps,
noHover,
elRef,
linkRef,
...props
}: Props) {
const component = (
<div
ref={elRef}
{...props}
className={twMerge(
"flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
"border border-slate-200 dark:border-white/10 border-solid",
noHover ? "" : "twui-card",
props.className,
)}
>
{props.children}
</div>
);
if (href) {
return (
<Link
ref={linkRef}
href={href}
{...linkProps}
className={twMerge(
"cursor-pointer",
"twui-card",
"twui-card-link",
linkProps?.className,
)}
>
{component}
</Link>
);
}
return component;
}
+61
View File
@@ -0,0 +1,61 @@
import type { ComponentProps, ReactNode } from "react";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import { Check, CheckCircle, CheckCircle2 } from "lucide-react";
import Span from "../layout/Span";
import { twMerge } from "tailwind-merge";
type BulletPoint = {
title: string;
icon?: ReactNode;
};
export type TWUI_CHECK_BULLET_POINTS_PROPS = ComponentProps<typeof Stack> & {
bulletPoints: BulletPoint[];
bulletWrapperProps?: ComponentProps<typeof Row>;
iconProps?: ComponentProps<typeof CheckCircle2>;
titleProps?: ComponentProps<typeof Span>;
};
/**
* # Check Bullet Points Component
* @className_wrapper twui-check-bullet-points-wrapper
*/
export default function CheckBulletPoints({
bulletPoints,
bulletWrapperProps,
iconProps,
titleProps,
...props
}: TWUI_CHECK_BULLET_POINTS_PROPS) {
return (
<Stack {...props} className={twMerge("gap-3", props.className)}>
{bulletPoints.map((bulletPoint, index) => {
return (
<Row
key={index}
{...bulletWrapperProps}
className={twMerge(
"gap-2 xl:flex-nowrap",
bulletWrapperProps?.className,
)}
>
{bulletPoint.icon || (
<CheckCircle2
className="text-success min-w-[20px]"
size={20}
{...iconProps}
/>
)}
<Span
{...titleProps}
className={twMerge("", titleProps?.className)}
>
{bulletPoint.title}
</Span>
</Row>
);
})}
</Stack>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { Check, Copy } from "lucide-react";
import React, {
type DetailedHTMLProps,
type HTMLAttributes,
type PropsWithChildren,
} from "react";
import { twMerge } from "tailwind-merge";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import Button from "../layout/Button";
import Divider from "../layout/Divider";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement> & {
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
"data-title"?: string;
backgroundColor?: string;
singleBlock?: boolean;
language?: (typeof TWUIPrismLanguages)[number];
};
/**
* # CodeBlock
*
* @className `twui-code-block-wrapper`
* @className `twui-code-pre-wrapper`
* @className `twui-code-block-pre`
* @className `twui-code-block-header`
*/
export default function CodeBlock({
children,
wrapperProps,
backgroundColor,
singleBlock,
language,
...props
}: Props) {
const codeRef = React.useRef<HTMLDivElement>(null);
const [copied, setCopied] = React.useState(false);
const title = props?.["data-title"];
const finalBackgroundColor = backgroundColor || "#28272b";
return (
<div
{...wrapperProps}
className={twMerge(
"outline-[1px] outline-slate-200 dark:outline-white/10",
`rounded w-full transition-all items-start`,
"relative max-w-[80vw] sm:max-w-[85vw] xl:max-w-[880px]",
"twui-code-block-wrapper",
wrapperProps?.className,
)}
style={{
boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined,
backgroundColor: finalBackgroundColor,
...props.style,
}}
>
<Stack
className={twMerge(
"gap-0 w-full overflow-x-auto relative",
"max-h-[600px] overflow-y-auto",
)}
>
<Row
className={twMerge(
"w-full px-1 h-10 sticky top-0 py-2",
singleBlock ? "absolute !bg-transparent" : "",
"twui-code-block-header",
)}
style={{
backgroundColor: finalBackgroundColor,
}}
>
{title && <span className="text-white/70">{title}</span>}
<div className="ml-auto">
{copied ? (
<Row>
<span className="text-white text-xs twui-code-block-copied-text">
Copied!
</span>
<div className="w-5 h-5 rounded-full bg-emerald-600 text-white flex items-center justify-center">
<Check size={15} />
</div>
</Row>
) : (
<Button
variant="ghost"
color="gray"
beforeIcon={<Copy size={17} color="white" />}
className="!p-1 !bg-transparent opacity-50"
onClick={() => {
const content =
codeRef.current?.textContent;
if (!content) {
window.alert("No Content to copy");
return;
}
window.navigator.clipboard
.writeText(content)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
});
}}
title="Copy Code Snippet"
/>
)}
</div>
</Row>
{!singleBlock && (
<Divider className="!border-white/10 sticky top-10" />
)}
<div
className={twMerge(
`p-1 w-full [&_pre]:!bg-transparent`,
singleBlock ? "" : "-mt-1",
"twui-code-pre-wrapper",
)}
ref={codeRef as any}
>
<pre
{...props}
className={twMerge(
"!my-0 whitespace-pre-wrap",
language ? `language-${language}` : "",
"twui-code-block-pre",
props.className,
)}
>
{children}
</pre>
</div>
</Stack>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { Moon, Sun } from "lucide-react";
import type { TWUIButtonSizeType } from "../layout/Button";
const SizeMap: Record<
TWUIButtonSizeType,
{ icon: number; className: string }
> = {
smaller: { icon: 16, className: "w-4 h-4" },
small: { icon: 20, className: "w-5 h-5" },
normal: { icon: 24, className: "w-6 h-6" },
large: { icon: 28, className: "w-7 h-7" },
larger: { icon: 32, className: "w-8 h-8" },
};
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
active?: boolean;
setActive?: React.Dispatch<React.SetStateAction<boolean>>;
iconWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultScheme?: "light" | "dark";
/**
* Icon size. Matches the twui `Button` size scale.
* @default "normal"
*/
size?: TWUIButtonSizeType;
};
/**
* # Color Scheme Loader
* @className_wrapper twui-color-scheme-selector
*/
export default function ColorSchemeSelector({
active: initialActive,
setActive: externalSetActive,
iconWrapperProps,
defaultScheme,
size = "normal",
...props
}: Props) {
const { icon: iconSize, className: sizeClassName } = SizeMap[size];
const [active, setActive] = React.useState(initialActive);
React.useEffect(() => {
const isDocumentDark =
document.documentElement.classList.contains("dark");
const isDocumentLight =
document.documentElement.classList.contains("light");
if (isDocumentDark) {
setActive(true);
return;
} else if (isDocumentLight) {
setActive(false);
return;
}
const existingTheme = localStorage.getItem("theme");
if (existingTheme === "dark") {
setActive(true);
} else if (existingTheme === "light") {
setActive(false);
} else if (defaultScheme) {
setActive(defaultScheme == "dark");
} 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;
document.documentElement.classList.remove("dark", "light");
document.documentElement.classList.add(active ? "dark" : "light");
}, [active]);
/**
* Only an explicit toggle is persisted, so visitors who never choose
* keep following their OS preference.
*/
function toggleScheme() {
const next = !active;
setActive(next);
externalSetActive?.(next);
localStorage.setItem("theme", next ? "dark" : "light");
}
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center",
"twui-color-scheme-selector",
props.className,
)}
>
<button
title="Color Scheme Selector Button"
onClick={toggleScheme}
className={twMerge(
"cursor-pointer hover:opacity-70 flex items-center justify-center",
)}
>
<div
{...iconWrapperProps}
className={twMerge(
"flex items-center justify-center",
sizeClassName,
iconWrapperProps?.className,
)}
>
{active == false && <Sun size={iconSize} />}
{active == true && <Moon size={iconSize} />}
</div>
</button>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import { Copy, type LucideProps } from "lucide-react";
import Button from "../layout/Button";
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
slugText: string;
justIcon?: boolean;
noIcon?: boolean;
title?: string;
outlined?: boolean;
successMsg?: string | ReactNode;
icon?: ReactNode;
iconProps?: LucideProps;
setToastOpen?: Dispatch<SetStateAction<boolean>>;
};
export default function CopySlug({
slugText,
justIcon,
noIcon,
title,
outlined,
successMsg,
iconProps,
icon,
setToastOpen,
...props
}: Props) {
return (
<Button
title={title || slugText}
size="smaller"
variant="ghost"
color="gray"
{...props}
onClick={(e) => {
navigator.clipboard.writeText(slugText).then(() => {
setToastOpen?.(false);
setTimeout(() => {
setToastOpen?.(true);
}, 100);
});
props.onClick?.(e);
}}
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
>
{noIcon
? null
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
{!justIcon && (title ? title : "Copy Slug")}
</Button>
);
}
+273
View File
@@ -0,0 +1,273 @@
import React, {
type DetailedHTMLProps,
type HTMLAttributes,
type PropsWithChildren,
} from "react";
import ReactDOM from "react-dom";
import { twMerge } from "tailwind-merge";
export const TWUIDropdownContentPositions = [
"left",
"bottom-left",
"top-left",
"top",
"bottom",
"right",
"bottom-right",
"top-right",
"center",
] as const;
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
target: React.ReactNode;
contentWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
targetWrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
debounce?: number;
openDebounce?: number;
hoverOpen?: boolean;
above?: boolean;
position?: (typeof TWUIDropdownContentPositions)[number];
topOffset?: number;
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
externalOpen?: boolean;
keepOpen?: boolean;
disableClickActions?: boolean;
};
/**
* # Toggle Component
* @className_wrapper twui-dropdown-wrapper
* @className_wrapper twui-dropdown-target
* @className_wrapper twui-dropdown-content
*
* @note use the class `cancel-link` to prevent popup open on click
*/
export default function Dropdown({
contentWrapperProps,
targetWrapperProps,
hoverOpen,
above,
debounce = 200,
openDebounce = 200,
target,
position = "center",
topOffset,
externalSetOpen,
keepOpen,
disableClickActions,
externalOpen,
...props
}: TWUI_DROPDOWN_PROPS) {
const [open, setOpen] = React.useState(externalOpen);
const [mounted, setMounted] = React.useState(false);
const [contentStyle, setContentStyle] = React.useState<React.CSSProperties>(
{},
);
const timeoutRef = React.useRef<any>(null);
const openTimeoutRef = React.useRef<any>(null);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const dropdownContentRef = React.useRef<HTMLDivElement>(null);
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (dropdownContentRef.current?.contains(targetEl)) return;
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
if (!closestWrapper) {
externalSetOpen?.(false);
return setOpen(false);
}
if (closestWrapper && closestWrapper !== dropdownRef.current) {
externalSetOpen?.(false);
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (keepOpen) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
React.useEffect(() => {
setOpen(externalOpen);
}, [externalOpen]);
React.useEffect(() => {
setMounted(true);
}, []);
/**
* Content is portaled to `document.body` so it renders above
* everything else on the page. Position is computed from the
* wrapper's bounding rect and kept in sync on scroll/resize.
*/
const updateContentPosition = React.useCallback(() => {
const wrapperEl = dropdownRef.current;
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
let style: React.CSSProperties;
switch (position) {
case "left":
style = {
left: rect.right,
top: centerY,
transform: "translateY(-50%)",
};
break;
case "right":
style = {
right: viewportWidth - rect.left,
top: centerY,
transform: "translateY(-50%)",
};
break;
case "bottom-left":
style = { left: rect.left, top: rect.bottom };
break;
case "bottom-right":
style = { right: viewportWidth - rect.right, top: rect.bottom };
break;
case "top":
style = {
left: centerX,
bottom: viewportHeight - rect.top,
transform: "translateX(-50%)",
};
break;
default:
style = {
left: centerX,
top: rect.bottom,
transform: "translateX(-50%)",
};
break;
}
if (
above &&
style.top !== undefined &&
position !== "left" &&
position !== "right"
) {
delete style.top;
style.bottom = viewportHeight - rect.top;
}
setContentStyle(style);
}, [position, above]);
React.useLayoutEffect(() => {
if (!open) return;
updateContentPosition();
window.addEventListener("scroll", updateContentPosition, true);
window.addEventListener("resize", updateContentPosition);
return () => {
window.removeEventListener("scroll", updateContentPosition, true);
window.removeEventListener("resize", updateContentPosition);
};
}, [open, updateContentPosition]);
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center relative",
"twui-dropdown-wrapper",
props.className,
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeoutRef.current);
window.clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = setTimeout(() => {
externalSetOpen?.(true);
setOpen(true);
}, openDebounce);
}}
onMouseLeave={(e) => {
if (!hoverOpen) return;
window.clearTimeout(openTimeoutRef.current);
timeoutRef.current = setTimeout(() => {
externalSetOpen?.(false);
setOpen(false);
}, debounce);
}}
onBlur={() => {
window.clearTimeout(timeoutRef.current);
}}
ref={dropdownRef}
>
<div
onClick={(e) => {
const targetEl = e.target as HTMLElement | null;
if (targetEl?.closest?.(".cancel-link")) return;
if (disableClickActions) return;
externalSetOpen?.(!open);
setOpen(!open);
}}
className={twMerge(
"cursor-pointer",
"twui-dropdown-target",
targetWrapperProps?.className,
)}
>
{target}
</div>
{mounted &&
ReactDOM.createPortal(
<div
{...contentWrapperProps}
className={twMerge(
"fixed z-[1000]",
above ? "mb-1" : "mt-1",
open ? "flex" : "hidden",
"twui-dropdown-content",
contentWrapperProps?.className,
)}
onMouseEnter={() => {
if (!hoverOpen) return;
window.clearTimeout(timeoutRef.current);
}}
onBlur={() => {
if (!hoverOpen) return;
window.clearTimeout(timeoutRef.current);
}}
style={{
...contentStyle,
...contentWrapperProps?.style,
}}
ref={dropdownContentRef}
>
{props.children}
</div>,
document.body,
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { 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 type TWUIEmptyContentProps = ComponentProps<typeof Stack> & {
title: string;
url?: string;
linkProps?: ComponentProps<typeof Link>;
borderProps?: ComponentProps<typeof Border>;
textProps?: ComponentProps<typeof Span>;
contentWrapperProps?: ComponentProps<typeof Row>;
icon?: ReactNode;
};
/**
* # EmptyC ontent Component
* @className twui-empty-content
* @className twui-empty-content-border
* @className twui-empty-content-link
*/
export default function EmptyContent({
title,
url,
linkProps,
icon,
borderProps,
textProps,
contentWrapperProps,
...props
}: TWUIEmptyContentProps) {
const mainComponent = (
<Stack
{...props}
className={twMerge("w-full", "twui-empty-content", props.className)}
>
<Border
{...borderProps}
className={twMerge(
"w-full",
borderProps?.className,
"twui-empty-content-border",
)}
>
<Center>
<Row {...contentWrapperProps}>
{icon && <div className="opacity-50">{icon}</div>}
<Span
size="small"
{...textProps}
className={twMerge(
"opacity-70 text-foreground-light dark:text-foreground-dark",
textProps?.className,
)}
>
{title}
</Span>
</Row>
</Center>
</Border>
</Stack>
);
if (url) {
return (
<Link
{...linkProps}
className={twMerge(
"w-full",
"twui-empty-content-link",
linkProps?.className,
)}
href={url}
>
{mainComponent}
</Link>
);
}
return mainComponent;
}
+33
View File
@@ -0,0 +1,33 @@
import type { ComponentProps } from "react";
import Link from "../layout/Link";
import type { 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>
);
}
+102
View File
@@ -0,0 +1,102 @@
import React, {
type DetailedHTMLProps,
type HTMLAttributes,
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import HeaderNavLinkComponent from "./HeaderNavLinkComponent";
export type TWUI_HEADER_NAV_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
headerLinks: TwuiHeaderLink[];
customDropdowns?: {
url: string;
content: ReactNode;
}[];
};
export type TwuiHeaderLink = {
title: string;
url: string;
strict?: boolean;
dropdown?: ReactNode;
children?: TwuiHeaderLink[];
icon?: ReactNode;
};
/**
* # Header Nav Component
* @className twui-header-nav
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNav({
headerLinks,
customDropdowns,
...props
}: TWUI_HEADER_NAV_PROPS) {
React.useEffect(() => {
twuiAddActiveLinksFn({ selector: ".twui-header-nav-link-component a" });
}, []);
return (
<nav
{...props}
className={twMerge(
"twui-header-nav w-full xl:w-auto",
props.className,
)}
>
<Row className="gap-x-2 gap-y-2 flex-col xl:flex-row items-start xl:items-stretch">
{headerLinks.map((link, index) => {
const targetCustomDropdown = customDropdowns?.find(
(d) => d.url == link.url,
);
return (
<HeaderNavLinkComponent
link={link}
key={index}
dropdown={targetCustomDropdown?.content}
/>
);
})}
</Row>
</nav>
);
}
type AddActiveLinkParams = {
selector?: string;
wrapperEl?: HTMLElement;
};
export function twuiAddActiveLinksFn({
selector,
wrapperEl,
}: AddActiveLinkParams) {
(wrapperEl || document).querySelectorAll(selector || "a").forEach((ln) => {
const linkEl = ln as HTMLAnchorElement;
const isLinkStrict = linkEl.dataset.strict;
const linkAttr = linkEl.getAttribute("href");
if (window.location.pathname === "/" && linkAttr == "/") {
linkEl.classList.add("active");
} else if (
isLinkStrict &&
linkEl.getAttribute("href") === window.location.pathname
) {
linkEl.classList.add("active");
} else if (
linkAttr &&
window.location.pathname.startsWith(linkAttr) &&
!isLinkStrict
) {
linkEl.classList.add("active");
}
});
}
+141
View File
@@ -0,0 +1,141 @@
import React, { type 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 type { TwuiHeaderLink } from "./HeaderNav";
import Card from "./Card";
import Stack from "../layout/Stack";
import Button from "../layout/Button";
/**
* # Header Nav Main Link Component
* @className twui-header-nav-link-component
* @className twui-header-nav-link-icon
* @className twui-header-nav-link-dropdown
*/
export default function HeaderNavLinkComponent({
link,
dropdown,
}: {
link: TwuiHeaderLink;
dropdown?: ReactNode;
}) {
const isDropdown = dropdown || link.dropdown || link.children?.[0];
const mainLinkComponent = (
<Row className="gap-0 grow">
<HeaderLink link={link} strict={link.strict} />
{isDropdown && (
<ChevronDown
className={twMerge(
"hidden xl:flex xl:-ml-1",
"twui-header-nav-link-icon",
)}
size={20}
/>
)}
</Row>
);
const [showMobileDropdown, setShowMobileDropdown] = React.useState(false);
return (
<div
className={twMerge(
"relative w-full xl:w-auto [&_a.active]:font-bold",
"twui-header-nav-link-component",
)}
>
{isDropdown ? (
<React.Fragment>
<Stack className="flex xl:hidden w-full">
<Row className="w-full justify-between">
{mainLinkComponent}
<Button
variant="ghost"
onClick={() =>
setShowMobileDropdown(!showMobileDropdown)
}
title="Header Links Dropdown Button"
>
<ChevronDown
className={twMerge(
"twui-header-nav-link-icon !text-link dark:!text-white",
)}
size={20}
/>
</Button>
</Row>
{showMobileDropdown && (
<Stack className="w-full">
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"w-full p-0",
"twui-header-nav-link-dropdown",
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map(
(_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
},
)}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Stack>
)}
</Stack>
<Dropdown
target={mainLinkComponent}
position="center"
hoverOpen
className="hidden xl:flex"
>
{dropdown ? (
dropdown
) : link.children?.[0] ? (
<Card
className={twMerge(
"min-w-[200px] mt-2 p-0",
"twui-header-nav-link-dropdown",
)}
>
<Stack className="w-full items-stretch gap-0 py-2">
{link.children.map((_ch, _index) => {
return (
<HeaderLink
link={_ch}
key={_index}
className="px-6 py-4"
/>
);
})}
</Stack>
</Card>
) : link.dropdown ? (
link.dropdown
) : null}
</Dropdown>
</React.Fragment>
) : (
mainLinkComponent
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
export type TWUI_HTML_TO_REACT_PROPS = Omit<
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
"dangerouslySetInnerHTML"
> & {
html: string;
componentRef?: React.RefObject<any>;
};
/**
* # HTML String to React Component
* Renders the HTML as-is, so only pass trusted or already-escaped HTML.
* Uses `dangerouslySetInnerHTML` rather than an HTML-to-React parser so
* it works in both server and browser bundles.
* @className_wrapper twui-html-react
*/
export default function HtmlToReactComponent({
html,
componentRef,
...props
}: TWUI_HTML_TO_REACT_PROPS) {
return (
<div
{...props}
className={twMerge("twui-html-react", props.className)}
ref={componentRef}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+161
View File
@@ -0,0 +1,161 @@
import React, {
type ComponentProps,
type DetailedHTMLProps,
type HTMLAttributes,
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import Link from "../layout/Link";
import { twuiAddActiveLinksFn } from "./HeaderNav";
import Row from "../layout/Row";
import Divider from "../layout/Divider";
import Button from "../layout/Button";
export type TWUI_LINK_LIST_LINK_OBJECT = {
title?: string;
component?: ReactNode;
url?: string;
strict?: boolean;
icon?: ReactNode;
iconPosition?: "before" | "after";
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
linkType?: "button" | "link";
divider?: ReactNode;
onClick?: React.MouseEventHandler<HTMLElement>;
};
export type TWUILink =
| TWUI_LINK_LIST_LINK_OBJECT
| TWUI_LINK_LIST_LINK_OBJECT[]
| undefined;
export type TWUI_LINK_LIST_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
links: TWUILink[];
linkProps?: ComponentProps<typeof Link>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
divider?: boolean;
dividerComponent?: ReactNode;
linkType?: "button" | "link";
};
/**
* # Link List Component
* @description A component that renders a list of links.
* @className_wrapper twui-link-list
*/
export default function LinkList({
links,
linkProps,
buttonProps,
divider,
dividerComponent,
linkType,
...props
}: TWUI_LINK_LIST_PROPS) {
const listRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
twuiAddActiveLinksFn({
wrapperEl: listRef.current || undefined,
selector: "a",
});
}, []);
return (
<div
ref={listRef}
{...props}
className={twMerge(
"flex flex-row items-center gap-1",
"twui-link-list",
props.className,
)}
>
{links
.flat()
.filter((ln) => Boolean(ln))
.map((link, index) => {
if (!link) return null;
if (link.divider)
return (
<React.Fragment key={index}>
{link.divider}
</React.Fragment>
);
const finalDivider =
index < links.length - 1 &&
(dividerComponent ? (
dividerComponent
) : divider ? (
<Divider />
) : undefined);
if (linkType == "button" || link.linkType == "button") {
return (
<React.Fragment key={index}>
<Button
title={link.title || "Link Button"}
variant="ghost"
{...buttonProps}
{...link.buttonProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
)}
onClick={(e) => {
link.onClick?.(e);
link.buttonProps?.onClick?.(e);
}}
>
<Row>
{link.icon}
{link.component || link.title}
</Row>
</Button>
{finalDivider}
</React.Fragment>
);
}
return (
<React.Fragment key={index}>
<Link
href={link.url}
title={link.title}
{...linkProps}
{...link.linkProps}
className={twMerge(
"p-2 cursor-pointer whitespace-nowrap",
linkProps?.className,
link.linkProps?.className,
)}
strict={link.strict}
onClick={(e) => {
link.onClick?.(e);
link.linkProps?.onClick?.(e);
}}
>
<Row>
{!link.iconPosition ||
link.iconPosition == "before"
? link.icon
: null}
{link.component || link.title}
{link.iconPosition == "after"
? link.icon
: null}
</Row>
</Link>
{finalDivider}
</React.Fragment>
);
})}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
size?: "small" | "normal" | "medium" | "large" | "smaller";
svgClassName?: string;
};
/**
* # Loading Component
* @className_wrapper twui-loading
*/
export default function Loading({ size, svgClassName, ...props }: Props) {
const sizeClassName = (() => {
switch (size) {
case "smaller":
return "w-4 h-4";
case "small":
return "w-5 h-5";
case "normal":
return "w-6 h-6";
case "large":
return "w-7 h-7";
default:
return "w-6 h-6";
}
})();
return (
<div
role="status"
{...props}
className={twMerge(`twui-loading`, props.className)}
>
<svg
aria-hidden="true"
className={twMerge(
"text-gray animate-spin dark:text-gray-dark fill-primary",
"dark:fill-white twui-loading",
sizeClassName,
svgClassName,
)}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-loading-block
*/
export default function LoadingBlock({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"bg-slate-200 dark:bg-white/10",
"rounded animate-pulse w-full h-[60px]",
"twui-loading-block",
props.className,
)}
>
{props.children}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Center from "../layout/Center";
import Loading from "./Loading";
import Row from "../layout/Row";
import Span from "../layout/Span";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
loadingProps?: ComponentProps<typeof Loading>;
label?: string;
fixed?: boolean;
};
/**
* # Loading Overlay Component
* @className_wrapper twui-loading-overlay
*/
export default function LoadingOverlay({
loadingProps,
label,
fixed,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"top-0 left-0 w-full h-full z-[500]",
"bg-background-light/90 dark:bg-background-dark/90",
fixed ? "fixed" : "absolute",
props.className,
"twui-loading-overlay",
)}
>
<Center>
<Row>
<Loading {...loadingProps} />
{label && <Span>{label}</Span>}
</Row>
</Center>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import ModalComponent from "../(partials)/ModalComponent";
import PopoverComponent from "../(partials)/PopoverComponent";
import { twMerge } from "tailwind-merge";
export const TWUIPopoverStyles = [
"top",
"bottom",
"left",
"right",
"transform",
"bottom-left",
"bottom-right",
] as const;
export const TWUIPopoverTriggers = ["hover", "click"] as const;
export type TWUI_MODAL_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
target?: React.ReactNode;
targetRef?: React.RefObject<HTMLDivElement>;
popoverReferenceRef?: React.RefObject<HTMLElement | null>;
targetWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
open?: boolean;
isPopover?: boolean;
position?: (typeof TWUIPopoverStyles)[number];
trigger?: (typeof TWUIPopoverTriggers)[number];
debounce?: number;
onClose?: () => any;
hoverOpen?: boolean;
no_cancel_button?: boolean;
};
/**
* # Modal Component
* @ID twui-modal-root
* @className twui-modal-content
* @className twui-modal
* @ID twui-popover-root
* @className twui-popover-content
* @className twui-popover-target
* @className twui-modal-content
* @className twui-modal-target
*/
export default function Modal(props: TWUI_MODAL_PROPS) {
const {
target,
targetRef,
targetWrapperProps,
open: existingOpen,
setOpen: existingSetOpen,
isPopover,
popoverReferenceRef,
trigger = "hover",
debounce = 500,
onClose,
hoverOpen,
no_cancel_button,
} = props;
const [ready, setReady] = React.useState(false);
const [open, setOpen] = React.useState(existingOpen || false);
React.useEffect(() => {
const IDName = isPopover ? "twui-popover-root" : "twui-modal-root";
const modalRoot = document.getElementById(IDName);
if (modalRoot) {
if (isPopover) {
modalRoot.style.zIndex = "1000";
}
setReady(true);
} else {
const newModalRootEl = document.createElement("div");
newModalRootEl.id = IDName;
document.body.appendChild(newModalRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
existingSetOpen?.(open);
if (open == false) onClose?.();
}, [open]);
React.useEffect(() => {
setOpen(existingOpen || false);
}, [existingOpen]);
const finalTargetRef = targetRef || React.useRef<HTMLDivElement>(null);
const finalPopoverReferenceRef = popoverReferenceRef || finalTargetRef;
const popoverTargetActiveRef = React.useRef(false);
const popoverContentActiveRef = React.useRef(false);
const closeTimeoutRef = React.useRef<any>(null);
const popoverEnterFn = React.useCallback((e: any) => {
popoverTargetActiveRef.current = true;
popoverContentActiveRef.current = false;
window.clearTimeout(closeTimeoutRef.current);
setOpen(true);
props.onMouseEnter?.(e);
}, []);
const popoverLeaveFn = React.useCallback((e: any) => {
window.clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = setTimeout(() => {
if (popoverContentActiveRef.current) {
popoverContentActiveRef.current = false;
return;
}
setOpen(false);
}, debounce);
props.onMouseLeave?.(e);
}, []);
const target_class_name = isPopover
? "twui-popover-target"
: "twui-modal-target";
const content_class_name = isPopover
? "twui-popover-content"
: "twui-modal-content";
const handleClickOutside = React.useCallback((e: MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (!isPopover) {
return;
}
const closestWrapper = targetEl.closest(`.${content_class_name}`);
const closestTarget = targetEl.closest(`.${target_class_name}`);
if (closestTarget) return;
if (!closestWrapper) {
return setOpen(false);
}
}, []);
React.useEffect(() => {
if (!isPopover) return;
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
return (
<React.Fragment>
{target ? (
<div
{...targetWrapperProps}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
}}
ref={finalTargetRef}
onMouseEnter={
isPopover && (trigger === "hover" || hoverOpen)
? popoverEnterFn
: targetWrapperProps?.onMouseEnter
}
onMouseLeave={
isPopover && (trigger === "hover" || hoverOpen)
? popoverLeaveFn
: targetWrapperProps?.onMouseLeave
}
className={twMerge(
target_class_name,
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>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { 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);
const next_button = (
<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>
);
return (
<Row
{...props}
className={twMerge(
"w-full justify-between md: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>
)}
{pages > 1 && <div className="flex md:hidden">{next_button}</div>}
<Row
className={twMerge(
"gap-6 w-full md:flex-nowrap justify-center",
)}
>
<Span
size="small"
variant="faded"
className="whitespace-nowrap"
>
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 && <div className="hidden md:flex">{next_button}</div>}
</Row>
);
}
+40
View File
@@ -0,0 +1,40 @@
import React, {
type DetailedHTMLProps,
type HTMLAttributes,
type RefObject,
} from "react";
import { twMerge } from "tailwind-merge";
/**
* # General paper
* @className_wrapper twui-paper
*/
export default function Paper({
variant,
linkProps,
componentRef,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
variant?: "normal";
linkProps?: DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
componentRef?: RefObject<HTMLDivElement | null>;
}) {
return (
<div
{...props}
ref={componentRef as any}
className={twMerge(
"flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full",
"relative",
"twui-paper",
props.className,
)}
>
{props.children}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import Modal, { type TWUI_MODAL_PROPS } from "./Modal";
/**
* # Popover Component
*/
export default function Popover(props: TWUI_MODAL_PROPS) {
return <Modal {...props} isPopover />;
}
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
import { MDXRemote, type MDXRemoteSerializeResult } from "next-mdx-remote";
import { useMDXComponents } from "../mdx/mdx-components";
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = {
content: string;
refresh?: number;
};
/**
* # CodeBlock
*
* @className `twui-remote-code-block-wrapper`
*/
export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>();
const { components } = useMDXComponents();
React.useEffect(() => {
serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
}).then((mdxSrc) => {
setMdxSource(mdxSrc);
});
}, [refresh]);
if (!mdxSource) {
return null;
}
return (
<MDXRemote
{...mdxSource}
components={{
...components,
}}
/>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { twMerge } from "tailwind-merge";
import Input from "../form/Input";
import Button from "../layout/Button";
import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react";
import React, {
type ComponentProps,
type DetailedHTMLProps,
type ReactNode,
} from "react";
let timeout: any;
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
dispatch?: (value?: string) => void;
changeHandler?: (value?: string) => void;
delay?: number;
inputProps?: ComponentProps<typeof Input>;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
loading?: boolean;
placeholder?: string;
componentRef?: React.RefObject<HTMLInputElement | null>;
beforeSearchIconComponent?: ReactNode;
no_search_button?: boolean;
};
/**
* # Search Component
* @className_wrapper twui-search-wrapper
* @className_circle twui-search-input
* @className_circle twui-search-button
*/
export default function Search<KeyType extends string>({
dispatch,
changeHandler,
delay = 500,
inputProps,
buttonProps,
loading,
placeholder,
componentRef,
beforeSearchIconComponent,
no_search_button,
...props
}: SearchProps<KeyType>) {
const [input, setInput] = React.useState(
props.defaultValue?.toString() || "",
);
React.useEffect(() => {
clearTimeout(timeout);
timeout = setTimeout(() => {
dispatch?.(input);
changeHandler?.(input);
}, delay);
}, [input]);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
// React.useEffect(() => {
// if (props.autoFocus) {
// inputRef.current?.focus();
// }
// }, []);
return (
<Row
{...props}
className={twMerge(
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
"twui-search-wrapper",
props?.className,
)}
>
<Input
type="search"
placeholder={placeholder || "Search"}
{...inputProps}
value={input}
onChange={(e) => {
setInput(e.target.value);
inputProps?.onChange?.(e as any);
}}
className={twMerge(
"rounded-r-none!",
"twui-search-input",
inputProps?.className,
)}
wrapperProps={{
className: "rounded-r-none!",
}}
componentRef={inputRef}
/>
{beforeSearchIconComponent}
{no_search_button ? null : (
<Button
loadingProps={{ size: "small" }}
{...buttonProps}
variant="outlined"
color="gray"
className={twMerge(
"rounded-l-none! ml-[1px]",
"twui-search-button",
buttonProps?.className,
)}
onClick={() => {
dispatch?.(input);
changeHandler?.(input);
}}
title="Search Button"
loading={loading}
>
<SearchIcon
className="text-slate-800 dark:text-white"
size={15}
/>
</Button>
)}
</Row>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React, { type DetailedHTMLProps, type PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren &
DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
/**
* # Single Line CodeBlock
*/
export default function SingleLineCodeBlock({ children, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"[&_.twui-code-block-header]:absolute [&_.twui-code-block-header]:!bg-transparent",
"[&_.twui-code-block-header]:mt-2 [&_.twui-code-block-header]:pr-3 [&_.twui-divider]:hidden",
"[&_pre]:!pr-14 [&_.twui-code-block-copied-text]:!hidden",
props.className,
)}
>
{children}
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { type LucideProps, Star } from "lucide-react";
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type StarProps = {
total?: number;
value?: number;
size?: number;
starProps?: LucideProps;
allowRating?: boolean;
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
changeHandler?: (value: number) => void;
};
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> &
StarProps;
let timeout: any;
/**
* # Star Rating Component
* @className_wrapper twui-star-rating
*/
export default function StarRating({
total = 5,
value = 0,
size,
starProps,
allowRating,
setValueExternal,
changeHandler,
...props
}: TWUI_STAR_RATING_PROPS) {
const totalArray = Array(total).fill(null);
const [finalValue, setFinalValue] = React.useState(value);
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
const starClicked = React.useRef(false);
const sectionHovered = React.useRef(false);
React.useEffect(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setValueExternal?.(finalValue);
}, 500);
}, [selectedStarValue]);
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center gap-0 -ml-[2px]",
"twui-star-rating",
props.className,
)}
onMouseEnter={() => {
sectionHovered.current = true;
}}
onMouseLeave={() => {
sectionHovered.current = false;
}}
>
{totalArray.map((_, index) => {
const isActive = index + 1 <= finalValue;
return (
<StarComponent
{...{
total,
value,
size,
starProps,
index,
allowRating,
finalValue,
setFinalValue,
starClicked,
selectedStarValue,
sectionHovered,
setSelectedStarValue,
isActive,
changeHandler,
}}
key={index}
/>
);
})}
</div>
);
}
function StarComponent({
size = 20,
starProps,
index,
allowRating,
setFinalValue,
starClicked,
sectionHovered,
setSelectedStarValue,
selectedStarValue,
isActive,
changeHandler,
}: StarProps & {
index: number;
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
starClicked: React.MutableRefObject<boolean>;
sectionHovered: React.MutableRefObject<boolean>;
selectedStarValue: number;
isActive: boolean;
}) {
return (
<div
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
onMouseEnter={() => {
if (!allowRating) return;
setFinalValue(index + 1);
}}
onMouseLeave={() => {
if (!allowRating) return;
setTimeout(() => {
if (sectionHovered.current) {
return;
}
if (!starClicked.current) {
setFinalValue(0);
}
if (selectedStarValue) {
setFinalValue(selectedStarValue);
}
}, 200);
}}
onClick={() => {
if (!allowRating) return;
starClicked.current = true;
setSelectedStarValue(index + 1);
changeHandler?.(index + 1);
}}
>
<Star
size={size}
className={twMerge(
"text-slate-300 dark:text-white/20",
isActive &&
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
// allowRating &&
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className,
)}
{...starProps}
/>
</div>
);
}
+76
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 opacity-50",
"font-semibold",
)}
title={header}
>
{header}
</th>
))}
</tr>
</thead>
<tbody
className={twMerge(
"bg-background-light dark:bg-background-dark",
"divide-y divide-gray dark:divide-gray-dark",
)}
>
{data.map((row, index) => (
<tr
key={index}
className={twMerge(
"hover:bg-gray/20 dark:hover:bg-gray-dark/10",
)}
>
{headers.map((header) => (
<td
key={`${header}-${index}`}
className={twMerge(
"px-3 py-2 whitespace-nowrap text-foreground-light",
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
"overflow-ellipsis",
)}
title={row[header]}
>
{row[header]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import React, { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import Border from "./Border";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
export type TWUITabsObject = {
title: string;
value?: string;
content?: React.ReactNode;
defaultActive?: boolean;
};
export type TWUI_TABS_PROPS = React.ComponentProps<typeof Stack> & {
tabsContentArray: (TWUITabsObject | TWUITabsObject[] | undefined | null)[];
tabsBorderProps?: React.ComponentProps<typeof Border>;
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
centered?: boolean;
debounce?: number;
/**
* React Component to display when switching
*/
switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
changeHandler?: (value: TWUITabsObject) => void;
defaultValue?: string | null;
hrefUpdate?: boolean;
};
/**
* # Tabs Component
* @className twui-tabs-wrapper
* @className twui-tab-buttons
* @className twui-tab-button-active
* @className twui-tab-buttons-wrapper
* @className twui-tab-buttons-container
* @className twui-tabs-border
*/
export default function Tabs({
tabsContentArray,
tabsBorderProps,
tabsButtonsWrapperProps,
centered,
debounce = 100,
switchComponent,
setActiveValue: existingSetActiveValue,
changeHandler,
defaultValue,
hrefUpdate,
...props
}: TWUI_TABS_PROPS) {
const finalTabsContentArray = tabsContentArray
.flat()
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
const values = finalTabsContentArray.map(
(obj) => obj.value || twuiSlugify(obj.title),
);
const defaultActiveObj = finalTabsContentArray.find(
(ctn) => ctn.defaultActive,
);
const [activeValue, setActiveValue] = React.useState(
defaultValue
? defaultValue
: defaultActiveObj
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
: values[0] || undefined,
);
const [ready, setReady] = React.useState(false);
const targetContent = finalTabsContentArray.find(
(ctn) =>
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
);
React.useEffect(() => {
if (!ready) return;
existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) {
changeHandler?.(targetContent);
if (hrefUpdate) {
const url = new URL(window.location.href);
url.searchParams.set("tab", activeValue);
window.history.pushState({}, "", url);
}
}
}, [activeValue]);
React.useEffect(() => {
if (hrefUpdate) {
const url = new URL(window.location.href);
const activeTab = url.searchParams.get("tab");
if (activeTab && activeValue !== activeTab) {
setActiveValue(undefined);
setActiveValue(activeTab);
}
setTimeout(() => {
setReady(true);
}, 500);
} else {
setReady(true);
}
}, []);
return (
<Stack
{...props}
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
>
<div
{...tabsButtonsWrapperProps}
className={twMerge(
"w-full",
"twui-tab-buttons-wrapper",
tabsButtonsWrapperProps?.className,
)}
>
<Border
className="p-0 w-full overflow-hidden twui-tabs-border"
{...tabsBorderProps}
>
<Row
className={twMerge(
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
centered && "justify-center",
"twui-tab-buttons-container",
)}
>
{values.map((value, index) => {
const targetObject = finalTabsContentArray.find(
(ctn) =>
ctn.value == value ||
twuiSlugify(ctn.title) == value,
);
const isActive = value == activeValue;
return (
<span
className={twMerge(
"px-6 py-2 rounded-default -ml-[1px] whitespace-nowrap",
isActive
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer",
"twui-tab-buttons",
)}
onClick={() => {
setActiveValue(undefined);
setTimeout(() => {
setActiveValue(value);
}, debounce);
}}
key={index}
>
{targetObject?.title}
</span>
);
})}
</Row>
</Border>
</div>
{activeValue ? targetContent?.content : switchComponent || null}
</Stack>
);
}
+70
View File
@@ -0,0 +1,70 @@
import React, { type PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
import {
ButtonClasses,
type TWUIButtonColorType,
type TWUIButtonVariantType,
} from "../layout/Button";
export type TWUI_TAG_PROPS = PropsWithChildren &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
color?: TWUIButtonColorType;
variant?: TWUIButtonVariantType;
href?: string;
newTab?: boolean;
linkProps?: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
};
/**
* # Tabs Component
* @className twui-tag
* @className twui-tag-primary-outlined
*/
export default function Tag({
color,
variant,
children,
href,
newTab,
linkProps,
...props
}: TWUI_TAG_PROPS) {
const finalClassName =
ButtonClasses[variant || "normal"]?.[color || "primary"] ?? "";
const mainComponent = (
<div
{...props}
className={twMerge(
"text-xs px-2 py-0.5 rounded-full outline-0",
"text-center flex items-center justify-center",
finalClassName,
"twui-tag",
props.className,
)}
>
{children}
</div>
);
if (href) {
return (
<a
href={href}
target={newTab ? "_blank" : undefined}
{...linkProps}
className={twMerge("hover:opacity-80", linkProps?.className)}
>
{mainComponent}
</a>
);
}
return mainComponent;
}
+117
View File
@@ -0,0 +1,117 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import Card from "./Card";
import { X } from "lucide-react";
import ReactDOM from "react-dom";
import Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
export type TWUIToastProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
closeDispatch?: (open?: boolean) => void;
closeDelay?: number;
color?: (typeof ToastStyles)[number];
};
let interval: any;
let timeout: any;
/**
* # Toast Component
* @className twui-toast-root
* @className twui-toast
* @className twui-toast-success
* @className twui-toast-error
*/
export default function Toast({
open,
setOpen,
closeDelay = 4000,
color,
closeDispatch,
...props
}: TWUIToastProps) {
const [ready, setReady] = React.useState(false);
const IDName = "twui-toast-root";
React.useEffect(() => {
const toastRoot = document.getElementById(IDName);
if (toastRoot) {
setReady(true);
} else {
const newToastRootEl = document.createElement("div");
newToastRootEl.id = IDName;
document.body.appendChild(newToastRootEl);
setReady(true);
}
}, []);
React.useEffect(() => {
if (!ready || !open) return;
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
return function () {
setOpen?.(false);
closeDispatch?.(open);
};
}, [ready, open]);
if (!ready) return null;
if (!open) return null;
return ReactDOM.createPortal(
<Card
{...props}
className={twMerge(
"fixed bottom-4 right-4 z-[250] border-none",
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
color == "success"
? "bg-success dark:bg-success-dark-fill twui-toast-success"
: color == "error"
? "bg-error dark:bg-error-dark-fill twui-toast-error"
: "",
props.className,
"twui-toast",
)}
onMouseEnter={() => {
window.clearTimeout(timeout);
}}
onMouseLeave={(e) => {
timeout = setTimeout(() => {
setOpen?.(false);
closeDispatch?.(open);
}, closeDelay);
}}
>
<Span
className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer",
"text-white",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen?.(false);
closeDispatch?.(open);
}}
>
<X size={15} />
</Span>
<Span className={twMerge("text-white! font-semibold")}>
{props.children}
</Span>
</Card>,
document.getElementById(IDName) as HTMLElement,
);
}
+77
View File
@@ -0,0 +1,77 @@
import {
type DetailedHTMLProps,
type HTMLAttributes,
useEffect,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
export type TWUI_TOGGLE_PROPS = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
circleProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
default_active?: boolean;
changeHandler?: (checked: boolean) => void;
};
/**
* # Toggle Component
* @className_wrapper twui-toggle-wrapper
* @className_circle twui-toggle-circle
*/
export default function Toggle({
circleProps,
default_active,
changeHandler,
...props
}: TWUI_TOGGLE_PROPS) {
const [active, setActive] = useState(default_active || false);
const [ready, setReady] = useState(false);
useEffect(() => {
if (!ready) return;
changeHandler?.(active);
}, [active]);
useEffect(() => {
setTimeout(() => {
setReady(true);
}, 200);
}, []);
return (
<div
{...props}
className={twMerge(
"flex flex-row items-center w-[40px] p-[3px] transition-all",
"border border-slate-300 dark:border-white/30 border-solid rounded-full",
active ? "justify-end" : "justify-start",
"twui-toggle-wrapper",
props.className,
)}
onClick={() => {
setActive(!active);
}}
>
{typeof active == "undefined" ? (
<div className="w-3.5 h-3.5 twui-toggle-circle"></div>
) : (
<div
{...circleProps}
className={twMerge(
"w-3.5 h-3.5 rounded-full ",
active
? "bg-blue-600 dark:bg-blue-500"
: "bg-slate-300 dark:bg-white/40",
"twui-toggle-circle",
circleProps?.className,
)}
></div>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { Send, X } from "lucide-react";
import React, { type Dispatch, type SetStateAction } from "react";
import type { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
import AIVoiceTranscription from "./AIVoiceTranscription";
import type { VoiceTranscriptionParams } from "./types";
type Props = VoiceTranscriptionParams & {
streamRes: string;
setStreamRes: Dispatch<SetStateAction<string>>;
setPrompt: Dispatch<SetStateAction<string>>;
loading: boolean;
promptFn: (prompt: string) => void;
history: ChatCompletionMessageParam[];
prompt: string;
currentPromptRef: React.MutableRefObject<string>;
promptInputRef: React.RefObject<HTMLTextAreaElement>;
user_image_url?: string;
last_response?: string;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
user_image_url,
transcribing,
voice_transcription_fn,
last_response,
cancel_voice_transcription_fn,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4 px-2">
<AIVoiceTranscription
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
history={history}
last_response={last_response}
/>
{streamRes.match(/./) && (
<React.Fragment>
<Button
title="Clear AI Result"
variant="ghost"
size="smaller"
color="gray"
className="px-0"
beforeIcon={<X size={20} />}
onClick={() => {
setStreamRes("");
}}
/>
<CopySlug
slugText={streamRes}
justIcon
iconProps={{ size: 18 }}
title="Copy Content"
/>
</React.Fragment>
)}
<AIPromptHistoryModal
history={history}
user_image_url={user_image_url}
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
/>
</Row>
<Row>
<Button
title="Send Prompt"
beforeIcon={<Send size={20} />}
loading={loading}
className="p-2"
onClick={() => {
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}}
loadingProps={{ size: "smaller" }}
disabled={transcribing}
/>
</Row>
</Row>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
import AIPromptBlockInputSection from "./AIPromptBlockInputSection";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
<Stack className="w-full">
{currentPromptRef.current && (
<Row className="w-full justify-end">
<Card className="py-1.5 px-2.5 text-xs">
<Row>
<Span>{currentPromptRef.current}</Span>
<MessageCircleMore
size={15}
opacity={0.5}
className="-mt-px"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
loading={loading}
/>
<AIPromptBlockInputSection
{...{
model,
promptFn,
history,
loading,
mdRes,
setMdRes,
placeholder,
}}
/>
</Stack>
</Paper>
);
}
@@ -0,0 +1,73 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Stack from "../../layout/Stack";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
placeholder?: string;
};
export default function AIPromptBlockInputSection({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
placeholder,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={
placeholder || (model ? `Prompt ${model}` : "Prompt AI")
}
wrapperProps={{ className: "outline-none" }}
wrapperWrapperProps={{ className: "w-full" }}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}
}}
componentRef={promptInputRef}
// autoFocus
/>
<AIPromptActionSection
loading={loading}
promptFn={promptFn}
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
prompt={prompt}
setPrompt={setPrompt}
currentPromptRef={currentPromptRef}
promptInputRef={promptInputRef as any}
/>
</Stack>
);
}
@@ -0,0 +1,77 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Stack from "../../layout/Stack";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import Img from "../../layout/Img";
import type { VoiceTranscriptionParams } from "./types";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import AIVoiceTranscription from "./AIVoiceTranscription";
import Span from "../../layout/Span";
import CopySlug from "../CopySlug";
import EmptyContent from "../EmptyContent";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam;
user_image_url?: string;
};
export default function AIPromptHistoryAssistantCard({
history,
user_image_url,
cancel_voice_transcription_fn,
transcribing,
voice_transcription_fn,
}: Props) {
const text_content = history.content?.toString();
if (!text_content) {
return <EmptyContent title="No Text response content" />;
}
return (
<Row className={"w-full items-start flex-nowrap"}>
<Stack>
<Border
className={twMerge(
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
"dark:bg-background-dark",
)}
>
<Bot />
</Border>
</Stack>
<Stack className="grow items-stretch">
<Card className="w-full overflow-x-auto xl:p-8">
<Stack className="twui-llm-chat-block">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{history.content?.toString()}
</ReactMarkdown>
</Stack>
</Card>{" "}
<Row>
<AIVoiceTranscription
transcribing={transcribing}
voice_transcription_fn={voice_transcription_fn}
cancel_voice_transcription_fn={
cancel_voice_transcription_fn
}
history={[history]}
last_response={history.content?.toString()}
/>
<CopySlug
slugText={text_content}
justIcon
iconProps={{ size: 18, opacity: 0.7 }}
title="Copy Content"
/>
</Row>
</Stack>
</Row>
);
}
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { History } from "lucide-react";
import Modal from "../Modal";
import Button from "../../layout/Button";
import Stack from "../../layout/Stack";
import H2 from "../../layout/H2";
import Span from "../../layout/Span";
import Divider from "../../layout/Divider";
import type { VoiceTranscriptionParams } from "./types";
import AIPromptHistoryUserCard from "./AIPromptHistoryUserCard";
import AIPromptHistoryAssistantCard from "./AIPromptHistoryAssistantCard";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam[];
user_image_url?: string;
};
export default function AIPromptHistoryModal({
history,
user_image_url,
...props
}: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="ghost"
className="p-1!"
>
<History size={20} opacity={0.7} />
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
>
<Stack className="gap-10 w-full">
<Stack className="gap-1">
<H2 className="!text-xl m-0">Chat History</H2>
<Span className="text-xs">
AI chat history for this session.
</Span>
</Stack>
<Divider />
{history.map((hst, index) => {
if (hst.role == "user") {
return (
<AIPromptHistoryUserCard
history={hst}
{...props}
key={index}
user_image_url={user_image_url}
/>
);
}
return (
<AIPromptHistoryAssistantCard
history={hst}
{...props}
key={index}
user_image_url={user_image_url}
/>
);
})}
</Stack>
</Modal>
);
}
@@ -0,0 +1,48 @@
import type { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { User } from "lucide-react";
import Stack from "../../layout/Stack";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import Img from "../../layout/Img";
import type { VoiceTranscriptionParams } from "./types";
type Props = VoiceTranscriptionParams & {
history: ChatCompletionMessageParam;
user_image_url?: string;
};
export default function AIPromptHistoryUserCard({
history,
user_image_url,
...props
}: Props) {
return (
<Row className="w-full items-start justify-end flex-nowrap">
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light",
"twui-llm-user-prompt-history-block",
)}
>
<Stack className="twui-llm-chat-block">
{history.content?.toString()}
</Stack>
</Card>
{user_image_url ? (
<Img
alt="Prompting user"
size={30}
circle
src={user_image_url}
/>
) : (
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
)}
</Row>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Divider from "../../layout/Divider";
import Stack from "../../layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
import type { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
loading?: boolean;
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
loading,
}: Props) {
const responseContentRef = React.useRef<HTMLDivElement>(null);
const isContentInterrupted = React.useRef(false);
React.useEffect(() => {
if (isContentInterrupted.current) return;
if (responseContentRef.current) {
responseContentRef.current.scrollTop =
responseContentRef.current.scrollHeight;
}
}, [streamRes]);
if (loading || !streamRes?.match(/./)) return null;
return (
<Stack className="w-full">
<MarkdownEditorPreviewComponent
value={streamRes}
maxHeight="40vh"
wrapperProps={{
className: "border-none p-0 ai-response-content",
componentRef: responseContentRef as any,
onMouseEnter: () => {
isContentInterrupted.current = true;
},
onMouseLeave: () => {
isContentInterrupted.current = false;
},
}}
/>
<Divider />
</Stack>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { Speech, Voicemail, X } from "lucide-react";
import Button from "../../layout/Button";
import type { ChatCompletionMessageParam } from "openai/resources";
import Row from "../../layout/Row";
import Loading from "../Loading";
import Border from "../Border";
type Props = {
voice_transcription_fn?: (params: { last_message?: string }) => void;
transcribing?: boolean;
history?: ChatCompletionMessageParam[];
last_response?: string;
cancel_voice_transcription_fn?: () => void;
};
export default function AIVoiceTranscription({
transcribing,
voice_transcription_fn,
history,
last_response,
cancel_voice_transcription_fn,
}: Props) {
if (!voice_transcription_fn) return null;
if (transcribing) {
return (
<Border className="px-1.5! py-0.5!">
<Row className="gap-2!">
<Loading size="small" />
<Button
title="Cancel Transcription"
className="p-1!"
onClick={(e) => {
e.preventDefault();
cancel_voice_transcription_fn?.();
}}
variant="ghost"
>
<X size={20} opacity={0.7} />
</Button>
</Row>
</Border>
);
}
return (
<Button
title="Listen to Speech"
size="smaller"
color="gray"
variant="ghost"
loading={transcribing}
onClick={(e) => {
e.preventDefault();
const history_last_response = history?.find(
(h) => h.role == "assistant",
);
voice_transcription_fn({
last_message:
last_response ||
history_last_response?.content?.toString(),
});
}}
className="p-1!"
>
<Speech size={20} opacity={0.7} />
</Button>
);
}
+5
View File
@@ -0,0 +1,5 @@
export type VoiceTranscriptionParams = {
transcribing?: boolean;
cancel_voice_transcription_fn?: () => void;
voice_transcription_fn?: (params: { last_message?: string }) => void;
};
+20
View File
@@ -0,0 +1,20 @@
import type { LucideProps } from "lucide-react";
import * as icons from "lucide-react";
import React from "react";
export type TWUILucideIconName = keyof typeof icons;
export type TWUILucideIconProps = LucideProps & {
name: TWUILucideIconName;
};
export default function LucideIcon({ name, ...props }: TWUILucideIconProps) {
const IconComponent = icons[name] as any;
if (!IconComponent) {
console.warn(`Lucide icon "${name}" not found`);
return null;
}
return <IconComponent {...props} />;
}
+149
View File
@@ -0,0 +1,149 @@
import React, {
type ComponentProps,
type DetailedHTMLProps,
type HTMLAttributes,
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import CheckMarkSVG from "../svgs/CheckMarkSVG";
import Stack from "../layout/Stack";
import Row from "../layout/Row";
import { Info } from "lucide-react";
import Span from "../layout/Span";
export type CheckboxProps = Omit<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>,
"title"
> & {
title?: string | ReactNode;
wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
label?: string | ReactNode;
labelProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
defaultChecked?: boolean;
wrapperClassName?: string;
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean;
readOnly?: boolean;
noLabel?: boolean;
size?: number;
changeHandler?: (value: boolean) => void;
clickDispatchHandler?: (value: boolean) => void;
info?: string | ReactNode;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
};
/**
* # Checkbox Component
* @className twui-checkbox
* @className twui-checkbox-checked
* @className twui-checkbox-unchecked
*/
export default function Checkbox({
wrapperProps,
label,
labelProps,
size,
wrapperClassName,
defaultChecked,
setChecked: externalSetChecked,
readOnly,
checked: externalChecked,
changeHandler,
info,
wrapperWrapperProps,
noLabel,
title,
clickDispatchHandler,
...props
}: CheckboxProps) {
const finalSize = size || 20;
const [checked, setChecked] = React.useState(
defaultChecked || externalChecked || false,
);
const finalTitle = title
? title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => {
if (typeof externalChecked == "undefined") return;
setChecked(externalChecked);
}, [externalChecked]);
React.useEffect(() => {
changeHandler?.(checked);
}, [checked]);
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1.5", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge(
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
readOnly ? "opacity-70 pointer-events-none" : "",
wrapperClassName,
wrapperProps?.className,
)}
onClick={(e) => {
setChecked(!checked);
externalSetChecked?.(!checked);
clickDispatchHandler?.(!checked);
}}
>
<div
{...props}
className={twMerge(
"flex items-center justify-center p-[3px] rounded-default",
checked
? "bg-primary twui-checkbox-checked text-white outline-slate-400"
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
"twui-checkbox",
props.className,
)}
style={{
minWidth: finalSize + "px",
width: finalSize + "px",
height: finalSize + "px",
...props.style,
}}
>
{checked && <CheckMarkSVG />}
</div>
{!noLabel && (
<Stack className="gap-0.5">
<div
{...labelProps}
className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className,
)}
>
{label || finalTitle}
</div>
</Stack>
)}
</div>
{info && (
<Row className="gap-1 flex-nowrap" title={info.toString()}>
<Info size={13} className="opacity-40 min-w-[20px]" />
<Span size="smaller" className="opacity-70">
{info}
</Span>
</Row>
)}
</Stack>
);
}
+426
View File
@@ -0,0 +1,426 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import { FileArchive, FilePlus2, X } from "lucide-react";
import React, {
type ComponentProps,
type DetailedHTMLProps,
type ReactNode,
} from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import type { FileInputToBase64FunctionReturn } from "../utils/form/fileInputToBase64";
import { twMerge } from "tailwind-merge";
import fileInputToBase64 from "../utils/form/fileInputToBase64";
import Row from "../layout/Row";
import Input from "./Input";
import Loading from "../elements/Loading";
import Tag from "../elements/Tag";
type FileInputUtils = {
clearFileInput?: () => void;
};
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils,
) => any;
changeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils,
) => any;
onClear?: () => void;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageWrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageProps?: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string | ReactNode;
disablePreview?: boolean;
allowedRegex?: RegExp;
externalSetFile?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
>;
externalSetFiles?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn[] | undefined>
>;
existingFile?: FileInputToBase64FunctionReturn;
existingFileUrl?: string;
icon?: ReactNode;
externalSetFileURL?: React.Dispatch<string | undefined>;
labelSpanProps?: ComponentProps<typeof Span>;
loading?: boolean;
multiple?: boolean;
};
/**
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
*/
export default function FileUpload({
onChangeHandler,
fileInputProps,
placeHolderWrapper,
previewImageWrapperProps,
previewImageProps,
label,
disablePreview,
allowedRegex,
externalSetFile,
externalSetFiles,
existingFile,
existingFileUrl,
icon,
labelSpanProps,
loading,
multiple,
onClear,
changeHandler,
externalSetFileURL,
...props
}: ImageUploadProps) {
const [file, setFile] = React.useState<
FileInputToBase64FunctionReturn | undefined
>(existingFile);
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
existingFileUrl,
);
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const tempFileURLRef = React.useRef<string>("");
React.useEffect(() => {
if (existingFileUrl) {
setFileUrl(existingFileUrl);
}
}, [existingFileUrl]);
React.useEffect(() => {
if (existingFile) {
setFile(existingFile);
}
}, [existingFile]);
function clearFileInput() {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}
const fileInputUtils: FileInputUtils = { clearFileInput };
return (
<Stack
{...props}
className={twMerge("w-full h-[300px]", props?.className)}
>
<input
type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
onChange={(e) => {
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let filesArr: FileInputToBase64FunctionReturn[] =
[];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await fileInputToBase64({
inputFile: file!,
});
filesArr.push(fileObj);
}
externalSetFiles?.(filesArr);
})();
} else {
const inputFile = e.target.files?.[0];
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(
res,
inputRef,
fileInputUtils,
);
changeHandler?.(res, inputRef, fileInputUtils);
fileInputProps?.onChange?.(e);
},
);
}
}}
ref={inputRef as any}
/>
{loading ? (
<Card className={twMerge("w-full h-full ")}>
<Center>
<Loading />
</Center>
</Card>
) : file ? (
<Card
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
"pb-10",
previewImageWrapperProps?.className,
)}
>
<Stack>
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : file.fileType?.match(/image/i) ? (
<img
src={file.fileBase64Full}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
) : (
<Stack>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span>
{file.file?.name || file.fileName}
</Span>
<Span size="smaller" className="opacity-70">
{file.fileType}
</Span>
</Stack>
</Stack>
)}
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"hover:bg-white dark:hover:bg-black",
)}
onClick={(e) => {
clearFileInput();
}}
title="Cancel File Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
<Input
value={file.fileName}
onChange={(e) => {
setFile({ ...file, fileName: e.target.value });
externalSetFile?.({
...file,
fileName: e.target.value,
});
}}
/>
</Stack>
</Card>
) : fileUrl ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
>
<Stack className="w-full">
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
<Row>
<FileArchive size={36} strokeWidth={1} />
<Stack className="gap-0">
<Span size="smaller" className="opacity-70">
{fileUrl}
</Span>
</Stack>
</Row>
) : (
<img
src={fileUrl}
className="w-full object-contain overflow-hidden"
{...previewImageProps}
/>
)}
<Tag
variant="outlined"
color="gray"
className="w-full py-2 text-sm"
>
{fileUrl}
</Tag>
</Stack>
<Button
variant="ghost"
className={twMerge(
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
"hover:bg-white dark:hover:bg-black",
)}
onClick={(e) => {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
setFileUrl(undefined);
}}
title="Cancel File Button"
>
<X className="text-slate-950 dark:text-white" />
</Button>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
placeHolderWrapper?.className,
)}
onClick={(e) => {
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
onDragOver={(e) => {
e.preventDefault();
setFileDraggedOver(true);
}}
onDragLeave={(e) => {
setFileDraggedOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setFileDraggedOver(false);
let inputFile: File | null = null;
if (e.dataTransfer.items) {
[...e.dataTransfer.items].forEach((item, i) => {
if (inputFile) return;
if (item.kind === "file") {
const file = item.getAsFile();
inputFile = file;
}
});
} else {
inputFile = e.dataTransfer.files?.[0]!;
}
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
changeHandler?.(res);
},
);
}}
{...placeHolderWrapper}
>
<Center
className={twMerge(
fileDraggedOver ? "pointer-events-none" : "",
)}
>
<Stack className="items-center gap-2">
{icon || <FilePlus2 className="text-slate-400" />}
<Span
size="smaller"
variant="faded"
{...labelSpanProps}
>
{label || "Click to Upload File"}
</Span>
{externalSetFileURL ? (
<Row
className="flex-nowrap gap-0 items-stretch"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Input
placeholder="Add Media URL"
className="text-sm"
wrapperProps={{ className: "h-full" }}
wrapperWrapperProps={{
className: "h-full",
}}
changeHandler={(value) => {
tempFileURLRef.current = value;
}}
onKeyUp={(e) => {
if (e.key == "Enter") {
if (tempFileURLRef.current) {
setFileUrl(
tempFileURLRef.current,
);
externalSetFileURL(
tempFileURLRef.current,
);
}
}
}}
/>
<Button
title="Add Media URL"
variant="outlined"
className="py-0 px-3"
color="gray"
onClick={(e) => {
e.preventDefault();
if (tempFileURLRef.current) {
setFileUrl(
tempFileURLRef.current,
);
externalSetFileURL(
tempFileURLRef.current,
);
}
}}
>
<span className="text-2xl">+</span>
</Button>
</Row>
) : null}
</Stack>
</Center>
</Card>
)}
</Stack>
);
}
+51
View File
@@ -0,0 +1,51 @@
import _ from "lodash";
import type { DetailedHTMLProps, FormHTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge";
type Props<T extends { [key: string]: any } = { [key: string]: any }> =
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
formRef?: RefObject<HTMLFormElement>;
};
/**
* # Form Element
* @className twui-form
*/
export default function Form<
T extends { [key: string]: any } = { [key: string]: any },
>({ formRef, ...props }: Props<T>) {
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
return (
<form
{...finalProps}
className={twMerge(
"flex flex-col items-stretch gap-2 w-full bg-transparent",
"twui-form",
props.className,
)}
onSubmit={(e) => {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.submitHandler?.(e, data);
props.onSubmit?.(e);
}}
onChange={(e) => {
e.preventDefault();
const taregtEl = e.target as HTMLElement;
const formEl = taregtEl.closest("form") as HTMLFormElement;
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries()) as T;
props.changeHandler?.(e, data);
props.onChange?.(e);
}}
ref={formRef}
>
{props.children}
</form>
);
}
+268
View File
@@ -0,0 +1,268 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import { ImagePlus, X } from "lucide-react";
import React, { type DetailedHTMLProps } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import imageInputToBase64, {
type ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag";
import Input from "./Input";
import Row from "../layout/Row";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
imgData: ImageInputToBase64FunctionReturn | undefined,
) => any;
multipleOnChangeHandler?: (
imgData: ImageInputToBase64FunctionReturn[] | undefined,
) => any;
fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageWrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
previewImageProps?: DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>;
label?: string;
disablePreview?: boolean;
multiple?: boolean;
existingImageUrl?: string;
externalSetImage?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn | undefined>
>;
externalSetImages?: React.Dispatch<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void;
upload_only?: boolean;
};
/**
* @note use the `onChangeHandler` prop to grab the parsed base64 image object
*/
export default function ImageUpload({
onChangeHandler,
fileInputProps,
placeHolderWrapper,
previewImageWrapperProps,
previewImageProps,
label,
disablePreview,
existingImageUrl,
externalSetImage,
externalSetImages,
externalImage,
multiple,
restoreImageFn,
setLoading,
setImgURL,
multipleOnChangeHandler,
upload_only,
...props
}: ImageUploadProps) {
const [imageObject, setImageObject] = React.useState<
ImageInputToBase64FunctionReturn | undefined
>(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null);
const imageUrlRef = React.useRef("");
React.useEffect(() => {
setImgURL?.(src);
}, [src]);
React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl);
}, [existingImageUrl]);
return (
<Stack
{...props}
className={twMerge(
"w-full h-[300px] overflow-hidden",
props?.className,
)}
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
onChange={(e) => {
setLoading?.(true);
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
let imgArr: ImageInputToBase64FunctionReturn[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileObj = await imageInputToBase64({
file,
});
imgArr.push(fileObj);
}
multipleOnChangeHandler?.(imgArr);
externalSetImages?.(imgArr);
setLoading?.(false);
})();
} else {
imageInputToBase64({ imageInput: e.target }).then(
(res) => {
setSrc(res.imageBase64Full);
onChangeHandler?.(res);
setImageObject?.(res);
externalSetImage?.(res);
fileInputProps?.onChange?.(e);
setLoading?.(false);
},
);
}
}}
ref={inputRef as any}
/>
{src || imageObject?.imageBase64Full ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
>
{label && (
<label
className={twMerge(
"absolute top-0 left-0 text-xs z-50",
)}
>
<Tag color="gray">
<span className="opacity-70">{label}</span>
</Tag>
</label>
)}
{disablePreview ? (
<Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : (
<img
src={imageObject?.imageBase64Full || src}
className="w-full h-full object-contain"
{...previewImageProps}
/>
)}
<div
className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"cursor-pointer",
)}
onClick={(e) => {
setSrc(undefined);
onChangeHandler?.(undefined);
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
}
}}
title="Cancel Image Upload Button"
>
<X className="text-slate-950 dark:text-white" />
</div>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className,
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined;
if (targetEl?.closest(".cancel-upload")) {
e.preventDefault();
return;
}
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
<ImagePlus className="text-slate-400" />
<Span size="smaller" variant="faded">
{label || "Click to Upload Image"}
</Span>
{upload_only ? null : (
<>
<Stack className="cancel-upload w-full items-stretch gap-1">
<Input
placeholder="URL Eg. https://example.com/img.png"
className="text-sm twui-image-url-input"
title="Enter Image URL"
wrapperWrapperProps={{
className: "mt-2",
}}
changeHandler={(value) => {
imageUrlRef.current = value;
}}
/>
<Button
title="Restore Image Button"
size="smaller"
variant="outlined"
color="gray"
onClick={() => {
if (!imageUrlRef.current)
return;
setSrc(imageUrlRef.current);
}}
className="opacity-70"
>
Set Image URL
</Button>
</Stack>
{existingImageUrl && (
<Button
title="Restore Image Button"
size="smaller"
variant="ghost"
onClick={() => {
restoreImageFn?.() ||
setSrc(existingImageUrl);
}}
className="cancel-upload"
>
Restore Original Image
</Button>
)}
</>
)}
</Stack>
</Center>
</Card>
)}
</Stack>
);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from "react";
import Row from "../../layout/Row";
import { Info, Minus, Plus } from "lucide-react";
import twuiNumberfy from "../../utils/numberfy";
import type { InputProps } from ".";
let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>;
};
/**
* # Input Number Text Buttons
*/
export default function NumberInputButtons({
value,
setValue,
min,
max,
step,
buttonDownRef,
inputRef,
decimal,
}: Props) {
const PRESS_TRIGGER_TIMEOUT = 200;
const DEFAULT_STEP = 1;
const [buttonDown, setButtonDown] = useState(false);
// 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;
// }
// }
useEffect(() => {
buttonDownRef.current = buttonDown;
if (buttonDown) {
setValue(inputRef.current?.value || "");
} else {
setTimeout(() => {
setValue(inputRef.current?.value || "");
}, 50);
}
}, [buttonDown]);
function incrementDownPress() {
window.clearTimeout(pressTimeout);
setButtonDown(true);
pressTimeout = setTimeout(() => {
pressInterval = setInterval(() => {
increment();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function incrementDownCancel() {
setButtonDown(false);
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function decrementDownPress() {
setButtonDown(true);
pressTimeout = setTimeout(() => {
pressInterval = setInterval(() => {
decrement();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
}
function decrementDownCancel() {
setButtonDown(false);
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
}
function increment() {
if (!inputRef.current) return;
const existingValue = inputRef.current.value;
const existingNumberValue = twuiNumberfy(existingValue, decimal);
let new_value = "";
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
new_value = twuiNumberfy(max, decimal).toLocaleString();
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
new_value = twuiNumberfy(min, decimal).toLocaleString();
} else {
new_value = (
existingNumberValue +
twuiNumberfy(step || DEFAULT_STEP, decimal)
).toLocaleString();
}
inputRef.current.value = new_value;
}
function decrement() {
if (!inputRef.current) return;
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue, decimal);
let new_value = "";
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
new_value = twuiNumberfy(min, decimal).toLocaleString();
} else {
new_value = (
existingNumberValue -
twuiNumberfy(step || DEFAULT_STEP, decimal)
).toLocaleString();
}
inputRef.current.value = new_value;
}
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>
);
}
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import type {
ComponentProps,
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
} from "react";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
import { twMerge } from "tailwind-merge";
type Value = {
value: string;
title?: string;
default?: boolean;
};
export type TWUI_FORM_RADIO_PROPS = {
values: Value[];
name: string;
inputProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
wrapperProps?: ComponentProps<typeof Row>;
changeHandler?: (value: string) => void;
};
/**
* # Form Radios Component
* @className twui-textarea
*/
export default function Radios({
values,
name,
inputProps,
labelProps,
wrapperProps,
changeHandler,
}: TWUI_FORM_RADIO_PROPS) {
const finalName = twuiSlugify(name);
const finalTitle = twuiSlugToNormalText(finalName);
return (
<Row
title={finalTitle}
{...wrapperProps}
className={twMerge("gap-4", wrapperProps?.className)}
>
{values.map((v, i) => {
const valueName = twuiSlugify(`${finalName}-${v.value}`);
const valueTitle = v.title || twuiSlugToNormalText(v.value);
return (
<Row key={i} className="gap-1.5">
<input
id={valueName}
type="radio"
defaultChecked={v.default}
name={finalName}
onChange={(e) => {
const targetValue = v.value;
changeHandler?.(targetValue);
}}
{...inputProps}
/>
<label htmlFor={valueName} {...labelProps}>
{valueTitle}
</label>
</Row>
);
})}
</Row>
);
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
import { ChevronDown, Info, type LucideProps } from "lucide-react";
import React, {
type ComponentProps,
type DetailedHTMLProps,
type Dispatch,
type InputHTMLAttributes,
type LabelHTMLAttributes,
type ReactNode,
type RefObject,
type SelectHTMLAttributes,
type SetStateAction,
} from "react";
import { twMerge } from "tailwind-merge";
import Row from "../layout/Row";
import Dropdown from "../elements/Dropdown";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Stack from "../layout/Stack";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
import Input from "./Input";
import Button from "../layout/Button";
export type TWUISelectValidityObject = {
isValid?: boolean;
msg?: string;
};
export type TWUISelectOptionObject<
KeyType extends string = string,
T extends { [k: string]: any } = { [k: string]: any },
> = {
title?: string;
value: KeyType;
default?: boolean;
data?: T;
};
export type TWUISelectProps<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any },
> = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement
> & {
options: TWUISelectOptionObject<KeyType, T>[];
label?: string;
showLabel?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
componentRef?: RefObject<HTMLSelectElement>;
iconProps?: LucideProps;
changeHandler?: (value: KeyType, data?: T) => void;
info?: string | ReactNode;
validateValueFn?: (value: string) => Promise<TWUISelectValidityObject>;
dispatchState?: Dispatch<SetStateAction<T | undefined>>;
name?: KeyType;
input_props?: ComponentProps<typeof Input>;
option_button_props?: Omit<ComponentProps<typeof Button>, "title">;
};
export type TWUISelectValueObject<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any },
> = {
value: KeyType;
data?: T;
};
/**
* # Select Element
* @className twui-select-wrapper
* @className twui-select
* @className twui-select-dropdown-icon
*/
export default function Select<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any },
>({
label,
options,
componentRef,
labelProps,
wrapperProps,
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
...props
}: TWUISelectProps<KeyType, T>) {
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value!,
data: options[0]?.data,
},
);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
const currentSelectValue = selectRef.current?.value;
if (currentSelectValue && validateValueFn) {
validateValueFn(currentSelectValue).then((res) => {
setValidity(res);
});
}
});
}, 200);
}, []);
React.useEffect(() => {
dispatchState?.(value.data);
}, [value]);
const selectID = label
? twuiSlugify(label)
: props.name
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
return (
<Stack
{...wrapperWrapperProps}
className={twMerge("gap-1", wrapperWrapperProps?.className)}
>
<div
{...wrapperProps}
className={twMerge(
"relative w-full flex items-center border rounded-default",
"border-slate-300 dark:border-white/20 pr-2",
"focus:border-slate-700 dark:focus:border-white/50",
"outline-slate-300 dark:outline-white/20",
"focus:outline-slate-700 dark:focus:outline-white/50",
"bg-white dark:bg-background-dark",
validity.isValid ? "" : "outline-warning border-warning",
wrapperProps?.className,
)}
>
{showLabel && (
<label
htmlFor={selectID}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/70 dark:bg-background-dark px-1.5 rounded-t",
"twui-input-label",
labelProps?.className,
)}
>
{label || props.title || props.name}
</label>
)}
<select
id={selectID}
aria-label={props["aria-label"] || props.title}
{...props}
className={twMerge(
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
"grow !border-none !outline-none",
"twui-select",
props.className,
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
onChange={(e) => {
const targetValue = options.find(
(opt) => opt.value == e.target.value,
);
if (targetValue) {
setValue(targetValue);
}
changeHandler?.(
e.target.value as (typeof options)[number]["value"],
targetValue?.data,
);
props.onChange?.(e);
validateValueFn?.(e.target.value).then((res) => {
setValidity(res);
});
}}
>
{options.flat().map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);
return (
<option key={index} value={option.value}>
{optionTitle}
</option>
);
})}
</select>
<ChevronDown
size={20}
{...iconProps}
className={twMerge(
"pointer-events-none -ml-6",
iconProps?.className,
)}
/>
{info && (
<Dropdown
target={
<div title="Select Info Button">
<Info size={20} />
</div>
}
hoverOpen
>
<Card className="min-w-[250px] p-6">
{typeof info == "string" ? (
<Span>{info}</Span>
) : (
info
)}
</Card>
</Dropdown>
)}
</div>
{!validity.isValid && validity.msg ? (
<Span size="smaller" className="text-warning">
{validity.msg}
</Span>
) : undefined}
</Stack>
);
}
+12
View File
@@ -0,0 +1,12 @@
import Input, { type InputProps } from "./Input";
/**
* # Textarea Component
* @className twui-textarea
*/
export default function Textarea<KeyType extends string>({
componentRef,
...props
}: InputProps<KeyType>) {
return <Input istextarea {...props} componentRef={componentRef} />;
}
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
type Param = {
/**
* Custom Event Name
*/
name: string;
};
/**
* # Dispatch Custom Event
*/
export default function useCustomEventDispatch<
T extends { [key: string]: any } = { [key: string]: any }
>({ name }: Param) {
const dispatchCustomEvent = React.useCallback((value: T | string) => {
let dataParsed = typeof value == "object" ? value : undefined;
const str = typeof value == "string" ? value : undefined;
if (str) {
try {
dataParsed = JSON.parse(str);
} catch (error) {}
}
const event = new CustomEvent(name, {
detail: {
data: dataParsed,
message: str,
},
});
window.dispatchEvent(event);
}, []);
return { dispatchCustomEvent };
}
+39
View File
@@ -0,0 +1,39 @@
import React from "react";
type Param = {
/**
* Custom Event Name
*/
name: string;
};
/**
* # Listen For Custom Event
*/
export default function useCustomEventListener<
T extends { [key: string]: any } = { [key: string]: any }
>({ name }: Param) {
const [data, setData] = React.useState<T | undefined>(undefined);
const [message, setMessage] = React.useState<string | undefined>(undefined);
const dataEventListenerCallback = React.useCallback((e: Event) => {
const customEvent = e as CustomEvent;
const eventPayloadMessage = customEvent.detail.message as
| string
| undefined;
const eventPayloadData = customEvent.detail.data as T | undefined;
if (eventPayloadMessage) setMessage(eventPayloadMessage);
if (eventPayloadData) setData(eventPayloadData);
}, []);
React.useEffect(() => {
window.addEventListener(name, dataEventListenerCallback, false);
return function () {
window.removeEventListener(name, dataEventListenerCallback);
};
}, []);
return { data, message };
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useRef } from "react";
type Param = {
elementRef?: React.RefObject<Element | undefined>;
className?: string;
elId?: string;
options?: IntersectionObserverInit;
removeIntersected?: boolean;
delay?: number;
};
export default function useIntersectionObserver({
elementRef,
className,
options,
removeIntersected,
delay,
elId,
}: Param) {
let timeoutRef = useRef<any>(null);
const [isIntersecting, setIsIntersecting] = React.useState(false);
const [refresh, setRefresh] = React.useState(0);
const observerTriggerDelay = delay || 200;
const observerCallback: IntersectionObserverCallback = React.useCallback(
(entries, observer) => {
const entry = entries[0];
window.clearTimeout(timeoutRef.current);
if (entry?.isIntersecting) {
timeoutRef.current = setTimeout(() => {
setIsIntersecting(true);
if (removeIntersected) {
observer.unobserve(entry.target);
}
}, observerTriggerDelay);
} else {
setIsIntersecting(false);
}
},
[],
);
React.useEffect(() => {
const element = elId
? document.getElementById(elId)
: elementRef?.current;
const elements = className
? document.querySelectorAll(`.${className}`)
: null;
if (!element && !className && refresh < 5) {
requestAnimationFrame(() => {
setTimeout(() => {
setRefresh(refresh + 1);
}, 2000);
});
return;
}
const observer = new IntersectionObserver(observerCallback, {
rootMargin: "0px 0px 0px 0px",
...options,
});
if (elements) {
elements.forEach((el) => {
observer.observe(el);
});
} else if (element) {
observer.observe(element);
}
}, [refresh]);
return { isIntersecting };
}
+31
View File
@@ -0,0 +1,31 @@
import React from "react";
export type UseLocalStorageParam<
T extends { [key: string]: any } = { [key: string]: any }
> = {
key: keyof T;
};
/**
* # Use Local Storage
*/
export default function useLocalStorage<
T extends Record<string, any> | undefined = undefined
>(param?: UseLocalStorageParam) {
const [data, setData] =
React.useState<T extends undefined ? string | null : T>();
React.useEffect(() => {
if (param?.key) {
const value = localStorage.getItem(param.key as string);
try {
const jsonValue = JSON.parse(value || "");
setData(jsonValue as any);
} catch (error) {
setData(value as any);
}
}
}, []);
return { data };
}
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
type Params = {
timeout?: number;
};
let timeout: any;
export default function useReady(params?: Params) {
const [ready, setReady] = React.useState(false);
const finalTimeout = params?.timeout || 300;
React.useEffect(() => {
requestAnimationFrame(() => {
window.clearTimeout(timeout);
timeout = setTimeout(() => {
setReady(true);
}, finalTimeout);
});
return function () {
window.clearTimeout(timeout);
};
}, []);
return { ready };
}
+44
View File
@@ -0,0 +1,44 @@
import React from "react";
export type TWUIErrorType = {
msg?: string;
};
type Params = {
initialLoading?: boolean;
initialReady?: boolean;
initialOpen?: boolean;
};
export type TWUIStatusType = {
msg?: string;
error?: boolean;
success?: boolean;
loading?: boolean;
};
export default function useStatus(params?: Params) {
const [refresh, setRefresh] = React.useState(0);
const [loading, setLoading] = React.useState(
params?.initialLoading || false,
);
const [status, setStatus] = React.useState<TWUIStatusType>();
const [ready, setReady] = React.useState(params?.initialReady || false);
const [open, setOpen] = React.useState(params?.initialOpen || false);
const [error, setError] = React.useState<TWUIErrorType>();
return {
refresh,
setRefresh,
loading,
setLoading,
status,
setStatus,
ready,
setReady,
open,
setOpen,
error,
setError,
};
}
+238
View File
@@ -0,0 +1,238 @@
import React, { useRef } from "react";
export type UseWebsocketHookParams = {
debounce?: number;
url: string;
disableReconnect?: boolean;
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
keepAliveDuration?: number;
/** Interval in ms to force-refresh the connection */
refreshConnection?: number;
};
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
/**
* # Use Websocket Hook
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
*
* @example window.addEventListener("wsDataEvent", (e)=>{
* console.log(e.detail.data) // type object
* })
* @example window.addEventListener("wsMessageEvent", (e)=>{
* console.log(e.detail.message) // type string
* })
*/
export default function useWebSocket<
T extends { [key: string]: any } = { [key: string]: any }
>({
url,
debounce,
disableReconnect,
keepAliveDuration,
refreshConnection,
}: UseWebsocketHookParams) {
const DEBOUNCE = debounce || 500;
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
const KEEP_ALIVE_MESSAGE = "twui::ping";
const tries = useRef(0);
// Refs to avoid stale closures in callbacks
const urlRef = useRef(url);
const disableReconnectRef = useRef(disableReconnect);
const keepAliveDurationRef = useRef(KEEP_ALIVE_DURATION);
React.useEffect(() => {
urlRef.current = url;
disableReconnectRef.current = disableReconnect;
keepAliveDurationRef.current = KEEP_ALIVE_DURATION;
});
const msgInterval = useRef<any>(null);
const sendInterval = useRef<any>(null);
const keepAliveInterval = useRef<any>(null);
const refreshInterval = useRef<any>(null);
const reconnectTimeout = useRef<any>(null);
const [socket, setSocket] = React.useState<WebSocket | undefined>(undefined);
const socketRef = useRef<WebSocket | undefined>(undefined);
const messageQueueRef = React.useRef<string[]>([]);
const sendMessageQueueRef = React.useRef<string[]>([]);
/**
* # Dispatch Custom Event
*/
const dispatchCustomEvent = React.useCallback(
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
const event = new CustomEvent(evtName, {
detail: {
data: value,
message: value,
},
});
window.dispatchEvent(event);
},
[]
);
/**
* # Connect to Websocket
*/
const connect = React.useCallback(() => {
const currentUrl = urlRef.current;
const domain = window.location.origin;
const wsURL = currentUrl.startsWith("ws")
? currentUrl
: domain.replace(/^http/, "ws") + ("/" + currentUrl).replace(/\/\//g, "/");
if (!wsURL) return;
const ws = new WebSocket(wsURL);
ws.onerror = () => {
console.log(`Websocket ERROR:`);
};
ws.onmessage = (ev) => {
messageQueueRef.current.push(ev.data);
};
ws.onopen = () => {
window.clearInterval(keepAliveInterval.current);
keepAliveInterval.current = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(KEEP_ALIVE_MESSAGE);
}
}, keepAliveDurationRef.current);
tries.current = 0;
socketRef.current = ws;
setSocket(ws);
console.log(`Websocket connected to ${wsURL}`);
};
ws.onclose = (ev) => {
console.log("Websocket closed!", {
code: ev.code,
reason: ev.reason,
wasClean: ev.wasClean,
});
window.clearInterval(keepAliveInterval.current);
socketRef.current = undefined;
setSocket(undefined);
if (disableReconnectRef.current) return;
if (tries.current >= 3) {
console.log("Max reconnect attempts reached.");
return;
}
tries.current += 1;
const backoff = Math.min(1000 * 2 ** tries.current, 30000);
console.log(`Attempting to reconnect in ${backoff}ms... (attempt ${tries.current})`);
reconnectTimeout.current = window.setTimeout(connect, backoff);
};
}, []);
/**
* # Initial Connection
*/
React.useEffect(() => {
connect();
return () => {
window.clearTimeout(reconnectTimeout.current);
window.clearInterval(keepAliveInterval.current);
window.clearInterval(refreshInterval.current);
socketRef.current?.close();
};
}, []);
/**
* # Refresh Connection Interval
*/
React.useEffect(() => {
if (!refreshConnection) return;
refreshInterval.current = window.setInterval(() => {
console.log("Refreshing WebSocket connection...");
window.clearTimeout(reconnectTimeout.current);
socketRef.current?.close();
tries.current = 0;
connect();
}, refreshConnection);
return () => window.clearInterval(refreshInterval.current);
}, [refreshConnection]);
React.useEffect(() => {
if (!socket) return;
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
return () => {
window.clearInterval(sendInterval.current);
window.clearInterval(msgInterval.current);
};
}, [socket]);
/**
* Received Message Queue Handler
*/
const handleReceivedMessageQueue = React.useCallback(() => {
try {
const msg = messageQueueRef.current.shift();
if (!msg) return;
const jsonData = JSON.parse(msg);
dispatchCustomEvent("wsMessageEvent", msg);
dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) {
console.log("Unable to parse string. Returning string.");
}
}, []);
/**
* Send Message Queue Handler
*/
const handleSendMessageQueue = React.useCallback(() => {
const ws = socketRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
window.clearInterval(sendInterval.current);
return;
}
const newMessage = sendMessageQueueRef.current.shift();
if (!newMessage) return;
ws.send(newMessage);
}, []);
/**
* # Send Data Function
*/
const sendData = React.useCallback((data: T) => {
try {
const queueItemJSON = JSON.stringify(data);
const existingQueue = sendMessageQueueRef.current.find(
(q) => q === queueItemJSON
);
if (!existingQueue) {
sendMessageQueueRef.current.push(queueItemJSON);
}
} catch (error: any) {
console.log("Error Sending socket message", error.message);
}
}, []);
return { socket, sendData };
}
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import { WebSocketEventNames } from "./useWebSocket";
type Param = {
listener?: (typeof WebSocketEventNames)[number];
};
/**
* # Use Websocket Data Event Handler Hook
*/
export default function useWebSocketEventHandler<
T extends { [key: string]: any } = { [key: string]: any }
>(param?: Param) {
const [data, setData] = React.useState<T | undefined>(undefined);
const [message, setMessage] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
const dataEventListenerCallback = (e: Event) => {
const customEvent = e as CustomEvent;
const data = customEvent.detail.data as T | undefined;
const __msg = customEvent.detail.message as string | undefined;
if (data) setData(data);
if (__msg && typeof __msg == "string") setMessage(__msg);
};
const messageEventName: (typeof WebSocketEventNames)[number] =
param?.listener || "wsDataEvent";
window.addEventListener(messageEventName, dataEventListenerCallback);
return function () {
window.removeEventListener(
messageEventName,
dataEventListenerCallback
);
};
}, []);
return { data, message };
}
+24
View File
@@ -0,0 +1,24 @@
import { useCallback, useEffect, useState } from "react";
export default function useWindowFocus() {
const [isWindowFocused, setIsWindowFocused] = useState(false);
const windowFocusCb = useCallback(() => {
setIsWindowFocused(true);
}, []);
const windowBlurCb = useCallback(() => {
setIsWindowFocused(false);
}, []);
useEffect(() => {
window.addEventListener("focus", windowFocusCb);
window.addEventListener("blur", windowBlurCb);
return function () {
window.removeEventListener("focus", windowFocusCb);
window.removeEventListener("blur", windowBlurCb);
};
}, []);
return { isWindowFocused };
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Root entry. Editors (Ace, TinyMCE), MDX, Next.js helpers, the AI blocks and
* RemoteCodeBlock rely on optional peer dependencies, so they are only
* available through deep imports, e.g. `@moduletrace/twui/editors/TinyMCE`.
*/
// layout
export { default as ArrowedLink } from "./layout/ArrowedLink";
export { default as Button } from "./layout/Button";
export * from "./layout/Button";
export { default as Center } from "./layout/Center";
export { default as Container } from "./layout/Container";
export { default as Divider } from "./layout/Divider";
export { default as DocsImg } from "./layout/DocsImg";
export { default as Footer } from "./layout/Footer";
export { default as H1 } from "./layout/H1";
export { default as H2 } from "./layout/H2";
export { default as H3 } from "./layout/H3";
export { default as H4 } from "./layout/H4";
export { default as H5 } from "./layout/H5";
export { default as HR } from "./layout/HR";
export { default as Header } from "./layout/Header";
export { default as IconLink } from "./layout/IconLink";
export { default as Img } from "./layout/Img";
export * from "./layout/Img";
export { default as Link } from "./layout/Link";
export { default as List } from "./layout/List";
export { default as LoadingRectangleBlock } from "./layout/LoadingRectangleBlock";
export * from "./layout/LoadingRectangleBlock";
export { default as Main } from "./layout/Main";
export { default as P } from "./layout/P";
export { default as Row } from "./layout/Row";
export { default as Section } from "./layout/Section";
export { default as Spacer } from "./layout/Spacer";
export { default as Span } from "./layout/Span";
export { default as Stack } from "./layout/Stack";
// elements
export { default as Border } from "./elements/Border";
export * from "./elements/Border";
export { default as Breadcrumbs } from "./elements/Breadcrumbs";
export * from "./elements/Breadcrumbs";
export { default as Card } from "./elements/Card";
export { default as CheckBulletPoints } from "./elements/CheckBulletPoints";
export * from "./elements/CheckBulletPoints";
export { default as CodeBlock } from "./elements/CodeBlock";
export * from "./elements/CodeBlock";
export { default as ColorSchemeSelector } from "./elements/ColorSchemeSelector";
export { default as CopySlug } from "./elements/CopySlug";
export { default as Dropdown } from "./elements/Dropdown";
export * from "./elements/Dropdown";
export { default as EmptyContent } from "./elements/EmptyContent";
export * from "./elements/EmptyContent";
export { default as HeaderLink } from "./elements/HeaderLink";
export * from "./elements/HeaderLink";
export { default as HeaderNav } from "./elements/HeaderNav";
export * from "./elements/HeaderNav";
export { default as HeaderNavLinkComponent } from "./elements/HeaderNavLinkComponent";
export { default as HtmlToReactComponent } from "./elements/HtmlToReactComponent";
export * from "./elements/HtmlToReactComponent";
export { default as LinkList } from "./elements/LinkList";
export * from "./elements/LinkList";
export { default as Loading } from "./elements/Loading";
export { default as LoadingBlock } from "./elements/LoadingBlock";
export { default as LoadingOverlay } from "./elements/LoadingOverlay";
export { default as Modal } from "./elements/Modal";
export * from "./elements/Modal";
export { default as Pagination } from "./elements/Pagination";
export { default as Paper } from "./elements/Paper";
export { default as Popover } from "./elements/Popover";
export { default as Search } from "./elements/Search";
export * from "./elements/Search";
export { default as SingleLineCodeBlock } from "./elements/SingleLineCodeBlock";
export { default as StarRating } from "./elements/StarRating";
export * from "./elements/StarRating";
export { default as Table } from "./elements/Table";
export { default as Tabs } from "./elements/Tabs";
export * from "./elements/Tabs";
export { default as Tag } from "./elements/Tag";
export * from "./elements/Tag";
export { default as Toast } from "./elements/Toast";
export * from "./elements/Toast";
export { default as Toggle } from "./elements/Toggle";
export * from "./elements/Toggle";
export { default as LucideIcon } from "./elements/lucide-icon";
export * from "./elements/lucide-icon";
// form
export { default as Checkbox } from "./form/Checkbox";
export * from "./form/Checkbox";
export { default as FileUpload } from "./form/FileUpload";
export { default as Form } from "./form/Form";
export { default as ImageUpload } from "./form/ImageUpload";
export { default as NumberInputButtons } from "./form/Input/NumberInputButtons";
export { default as Input } from "./form/Input";
export * from "./form/Input";
export { default as Radios } from "./form/Radios";
export * from "./form/Radios";
export { default as SearchSelect } from "./form/SearchSelect";
export * from "./form/SearchSelect";
export { default as Select } from "./form/Select";
export * from "./form/Select";
export { default as Textarea } from "./form/Textarea";
// hooks
export { default as useCustomEventDispatch } from "./hooks/useCustomEventDispatch";
export { default as useCustomEventListener } from "./hooks/useCustomEventListener";
export { default as useIntersectionObserver } from "./hooks/useIntersectionObserver";
export { default as useLocalStorage } from "./hooks/useLocalStorage";
export * from "./hooks/useLocalStorage";
export { default as useReady } from "./hooks/useReady";
export { default as useStatus } from "./hooks/useStatus";
export * from "./hooks/useStatus";
export { default as useWebSocket } from "./hooks/useWebSocket";
export * from "./hooks/useWebSocket";
export { default as useWebSocketEventHandler } from "./hooks/useWebSocketEventHandler";
export { default as useWindowFocus } from "./hooks/userWindowFocus";
// utils
export { default as twuiCamelToNormalCase } from "./utils/camel-to-normal-case";
export { default as TWUIEJSON } from "./utils/ejson";
export { default as fetchApi } from "./utils/fetch/fetchApi";
export * from "./utils/fetch/fetchApi";
export { default as fileInputToBase64 } from "./utils/form/fileInputToBase64";
export * from "./utils/form/fileInputToBase64";
export { default as imageInputToBase64 } from "./utils/form/imageInputToBase64";
export * from "./utils/form/imageInputToBase64";
export { default as lowerToTitleCase } from "./utils/lower-to-title-case";
export { default as twuiNormalizeText } from "./utils/normalize-text";
export { default as twuiNumberfy } from "./utils/numberfy";
export { default as TWUIRoundToNearestMagnitude } from "./utils/round-to-nearest-magnitude";
export { default as twuiSerializeQuery } from "./utils/serialize-query";
export { default as twuiSlugToNormalText } from "./utils/slug-to-normal-text";
export { default as twuiSlugify } from "./utils/slugify";
// svgs
export { default as CheckMarkSVG } from "./svgs/CheckMarkSVG";
// types
export * from "./types";
+25
View File
@@ -0,0 +1,25 @@
import type { ComponentProps, ReactNode } from "react";
import { ArrowUpRight } from "lucide-react";
import type { 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>
);
}
+367
View File
@@ -0,0 +1,367 @@
import React, {
type AnchorHTMLAttributes,
type ButtonHTMLAttributes,
type ComponentProps,
type DetailedHTMLProps,
type HTMLAttributeAnchorTarget,
type HTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import Loading from "../elements/Loading";
import LucideIcon, { type TWUILucideIconName } from "../elements/lucide-icon";
import type { LucideProps } from "lucide-react";
export const TWUIButtonColors = [
"primary",
"secondary",
"text",
"white",
"accent",
"gray",
"error",
"warning",
"success",
] as const;
export const TWUIButtonSizes = [
"small",
"smaller",
"normal",
"large",
"larger",
] as const;
export const TWUIButtonVariants = ["normal", "ghost", "outlined"] as const;
export type TWUIButtonColorType = (typeof TWUIButtonColors)[number];
export type TWUIButtonSizeType = (typeof TWUIButtonSizes)[number];
export type TWUIButtonVariantType = (typeof TWUIButtonVariants)[number];
export type TWUIButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
title: string;
variant?: TWUIButtonVariantType;
color?: TWUIButtonColorType;
size?: TWUIButtonSizeType;
loadingIconSize?: React.ComponentProps<typeof Loading>["size"];
href?: string;
target?: HTMLAttributeAnchorTarget;
loading?: boolean;
linkProps?: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>;
beforeIcon?: TWUILucideIconName | React.JSX.Element;
afterIcon?: TWUILucideIconName | React.JSX.Element;
before_icon_name?: TWUILucideIconName;
after_icon_name?: TWUILucideIconName;
before_icon_props?: LucideProps;
after_icon_props?: LucideProps;
buttonContentProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
loadingProps?: ComponentProps<typeof Loading>;
};
export const ButtonClasses: Record<string, Record<string, string>> = {
normal: {
primary: twMerge(
"bg-primary hover:bg-primary-hover text-white",
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover",
"twui-button-primary",
),
secondary: twMerge(
"bg-secondary hover:bg-secondary-hover text-white",
"dark:bg-secondary-dark hover:dark:bg-secondary-dark-hover dark:text-secondary-dark-text",
"twui-button-secondary",
),
white: twMerge(
"!bg-white hover:!bg-slate-200 !text-slate-800",
"twui-button-white",
),
accent: twMerge(
"bg-accent hover:bg-accent-hover text-white",
"dark:bg-accent-dark hover:dark:bg-accent-dark-hover dark:text-accent-dark-text",
"twui-button-accent",
),
gray: twMerge(
"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",
),
success: twMerge(
"bg-success hover:bg-success-hover text-white",
"dark:bg-success-dark-fill hover:dark:bg-success-dark-fill-hover dark:text-success-dark-text",
"twui-button-success",
),
error: twMerge(
"bg-error hover:bg-error-hover text-white",
"dark:bg-error-dark-fill hover:dark:bg-error-dark-fill-hover dark:text-error-dark-text",
"twui-button-error",
),
warning: twMerge(
"bg-warning hover:bg-warning-hover text-white",
"dark:bg-warning-dark-fill hover:dark:bg-warning-dark-fill-hover dark:text-warning-dark-text",
"twui-button-warning",
),
},
outlined: {
primary: twMerge(
"bg-transparent outline outline-1 outline-primary-outline",
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
"twui-button-primary-outlined",
),
secondary: twMerge(
"bg-transparent outline outline-1 outline-secondary",
"text-secondary dark:text-secondary-dark dark:outline-secondary-dark-outline",
"twui-button-secondary-outlined",
),
accent: twMerge(
"bg-transparent outline outline-1 outline-accent",
"text-accent",
"twui-button-accent-outlined",
),
gray: twMerge(
"bg-transparent outline outline-1 outline-gray",
"text-dark dark:text-white/60 dark:outline-white/30",
"twui-button-gray-outlined",
),
white: twMerge(
"bg-transparent outline outline-1 outline-white/50",
"text-white",
"twui-button-white-outlined",
),
error: twMerge(
"bg-transparent outline outline-1 outline-error text-error",
"dark:outline-error-dark dark:text-error-dark",
"twui-button-error-outlined",
),
warning: twMerge(
"bg-transparent outline outline-1 outline-warning text-warning",
"dark:outline-warning-dark dark:text-warning-dark",
"twui-button-warning-outlined",
),
success: twMerge(
"bg-transparent outline outline-1 outline-success text-success",
"dark:outline-success-dark dark:text-success-dark",
"twui-button-success-outlined",
),
},
ghost: {
primary: twMerge(
"bg-transparent dark:bg-transparent outline-none p-2",
"text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
"twui-button-primary-ghost",
),
secondary: twMerge(
"bg-transparent dark:bg-transparent outline-none p-2",
"text-secondary dark:text-secondary-dark hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost",
),
text: 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",
),
accent: twMerge(
"bg-transparent dark:bg-transparent outline-none p-2",
"text-accent hover:bg-transparent dark:hover:bg-transparent",
"twui-button-accent-ghost",
),
gray: twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 hover:bg-transparent dark:hover:bg-transparent",
"text-slate-600 dark:text-white/70 hover:opacity-80",
"twui-button-gray-ghost",
),
error: twMerge(
"bg-transparent outline-none p-2",
"text-error dark:text-error-dark",
"twui-button-error-ghost",
),
warning: twMerge(
"bg-transparent outline-none p-2",
"text-warning dark:text-warning-dark",
"twui-button-warning-ghost",
),
success: twMerge(
"bg-transparent outline-none p-2",
"text-success dark:text-success-dark",
"twui-button-success-ghost",
),
white: twMerge(
"bg-transparent outline-none p-2",
"text-white",
"twui-button-white-ghost",
),
},
};
/**
* # Buttons
* @className twui-button-general
* @className twui-button-content-wrapper
* @className twui-button-primary
* @className twui-button-primary-outlined
* @className twui-button-primary-ghost
* @className twui-button-secondary
* @className twui-button-secondary-outlined
* @className twui-button-secondary-ghost
* @className twui-button-white
* @className twui-button-white-outlined
* @className twui-button-white-ghost
* @className twui-button-accent
* @className twui-button-accent-outlined
* @className twui-button-accent-ghost
* @className twui-button-gray
* @className twui-button-gray-outlined
* @className twui-button-gray-ghost
*
* @example
```css
CSS directive:
//@theme inline {
--breakpoint-xs: 350px;
--color-primary: #000000;
--color-primary-hover: #29292b;
--color-primary-outline: #29292b;
--color-primary-text: #29292b;
--color-primary-dark: #29292b;
--color-primary-dark-hover: #4b4b4b;
--color-primary-dark-outline: #4b4b4b;
--color-primary-dark-text: #4b4b4b;
--color-secondary: #000000;
--color-secondary-hover: #dddddd;
--color-secondary-outline: #dddddd;
--color-secondary-text: #dddddd;
--color-secondary-dark: #000000;
--color-secondary-dark-hover: #dddddd;
--color-secondary-dark-outline: #dddddd;
--color-secondary-dark-text: #dddddd;
--color-accent: #000000;
--color-accent-hover: #dddddd;
--color-accent-outline: #dddddd;
--color-accent-text: #dddddd;
--color-accent-dark: #000000;
--color-accent-dark-hover: #dddddd;
--color-accent-dark-outline: #dddddd;
--color-accent-dark-text: #dddddd;
}
```
*/
export default function Button({
href,
target,
variant,
color,
size,
buttonContentProps,
linkProps,
beforeIcon,
afterIcon,
loading,
loadingIconSize,
loadingProps,
before_icon_name,
after_icon_name,
before_icon_props,
after_icon_props,
...props
}: TWUIButtonProps) {
const finalClassName =
ButtonClasses[variant || "normal"]?.[color || "primary"] ?? "";
const buttonComponent = (
<button
{...props}
className={twMerge(
"bg-primary text-white font-medium px-4 py-2 rounded-default",
"flex items-center justify-center relative transition-all cursor-pointer",
props.disabled ? "opacity-40 cursor-not-allowed" : "",
"twui-button-general",
size == "small"
? "px-3 py-1.5 twui-button-small text-sm"
: size == "smaller"
? "px-2 py-1 text-xs twui-button-smaller"
: size == "large"
? "text-lg twui-button-large"
: size == "larger"
? "px-5 py-3 text-xl twui-button-larger"
: "text-sm twui-button-base",
finalClassName,
loading ? "pointer-events-none opacity-80" : "",
props.className,
)}
aria-label={props.title}
>
<div
{...buttonContentProps}
className={twMerge(
"flex flex-row items-center gap-2 whitespace-nowrap",
loading ? "opacity-0" : "",
"twui-button-content-wrapper",
buttonContentProps?.className,
)}
>
{before_icon_name ? (
<LucideIcon
{...before_icon_props}
name={before_icon_name}
/>
) : beforeIcon ? (
typeof beforeIcon == "string" ? (
<LucideIcon name={beforeIcon as TWUILucideIconName} />
) : (
beforeIcon
)
) : null}
{props.children}
{after_icon_name ? (
<LucideIcon {...after_icon_props} name={after_icon_name} />
) : afterIcon ? (
typeof afterIcon == "string" ? (
<LucideIcon name={afterIcon as TWUILucideIconName} />
) : (
afterIcon
)
) : null}
</div>
{loading && (
<Loading
size={(() => {
if (loadingIconSize) return loadingIconSize;
switch (size) {
case "small":
return "small";
case "smaller":
return "smaller";
default:
return "normal";
}
})()}
{...loadingProps}
className={twMerge("absolute", loadingProps?.className)}
/>
)}
</button>
);
if (href)
return (
<a
{...linkProps}
href={href}
target={target}
title={props.title}
aria-label={props.title}
>
{buttonComponent}
</a>
);
return buttonComponent;
}
+23
View File
@@ -0,0 +1,23 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Flexbox Column
* @className twui-center
*/
export default function Center({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"flex flex-col items-center justify-center gap-4 p-2 w-full",
"h-full twui-center",
props.className,
)}
>
{props.children}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Default Container
* @className twui-container
*/
export default function Container({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
return (
<div
{...props}
className={twMerge(
"flex w-full max-w-container gap-4 justify-between",
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
"twui-container",
props.className,
)}
>
{props.children}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
vertical?: boolean;
dashed?: boolean;
};
/**
* # Vertical and Horizontal Divider
* @className twui-divider
* @className twui-divider-horizontal
* @className twui-divider-vertical
*/
export default function Divider({ vertical, dashed, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"border-slate-200 dark:border-white/10",
vertical
? "border-0 border-l h-full min-h-5"
: "border-0 border-t w-full",
"twui-divider",
vertical ? "twui-divider-vertical" : "twui-divider-horizontal",
dashed ? "border-dashed" : "border-solid",
props.className,
)}
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { twMerge } from "tailwind-merge";
import Img, { type 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>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General Footer
* @className twui-footer
*/
export default function Footer({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
return (
<footer
{...props}
className={twMerge(
"flex flex-col items-center justify-center",
"px-4 sm:px-10 py-2",
"border-0 border-b border-slate-200 border-solid",
"twui-footer",
props.className,
)}
>
{props.children}
</footer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # H1 Headers
* @className twui-h1
*/
export default function H1({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return (
<h1
{...props}
className={twMerge(
"text-4xl md:text-5xl mb-4",
"twui-headings twui-heading",
"twui-h1",
props.className,
)}
>
{props.children}
</h1>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # H2 Headers
* @className twui-h2
*/
export default function H2({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return (
<h2
{...props}
className={twMerge(
"text-2xl md:text-3xl mb-4",
"twui-headings twui-heading",
"twui-h2",
props.className,
)}
>
{props.children}
</h2>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # H3 Headers
* @className twui-h3
*/
export default function H3({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return (
<h3
{...props}
className={twMerge(
"text-xl mb-4",
"twui-headings twui-heading",
"twui-h3",
props.className,
)}
>
{props.children}
</h3>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # H4 Headers
* @className twui-h4
*/
export default function H4({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return (
<h4
{...props}
className={twMerge(
"text-base mb-4",
"twui-headings twui-heading",
"twui-h4",
props.className,
)}
>
{props.children}
</h4>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # H5 Headers
* @className twui-h5
*/
export default function H5({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) {
return (
<h5
{...props}
className={twMerge(
"mb-4",
"twui-headings twui-heading",
"twui-h5",
props.className,
)}
>
{props.children}
</h5>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Horizonta Rule (hr)
* @className twui-hr
*/
export default function HR({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHRElement>, HTMLHRElement>) {
return (
<hr
{...props}
className={twMerge(
"border-slate-200 dark:border-white/10 w-full my-4",
"twui-hr",
props.className,
)}
/>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Default Header
* @className twui-header
*/
export default function Header({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
return (
<header
{...props}
className={twMerge(
"flex flex-col items-center justify-center",
"px-4 sm:px-10 py-3 flex-wrap",
"border-0 border-b border-slate-200 dark:border-white/10 border-solid",
"twui-header",
props.className,
)}
>
{props.children}
</header>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ComponentProps, ReactNode } from "react";
import type { 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>
);
}
+158
View File
@@ -0,0 +1,158 @@
import _ from "lodash";
import React, {
type DetailedHTMLProps,
type ImgHTMLAttributes,
type ReactNode,
useEffect,
} from "react";
import { twMerge } from "tailwind-merge";
export type TWUIImageProps = DetailedHTMLProps<
ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
> & {
alt: string;
size?: number;
circle?: boolean;
bgImg?: boolean;
backgroundImage?: boolean;
fallbackImageSrc?: string;
srcLight?: string;
srcDark?: string;
imgErrSrc?: string;
imgErrComp?: ReactNode;
imgErrSrcLight?: string;
imgErrSrcDark?: string;
checkImageFn?: () => Promise<boolean> | boolean;
};
/**
* # Image Component
* @className twui-img
*/
export default function Img({
imgErrSrc,
imgErrComp,
imgErrSrcDark,
imgErrSrcLight,
checkImageFn,
...props
}: TWUIImageProps) {
const width = props.size || props.width;
const height = props.size || props.height;
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
const [imageError, setImageError] = React.useState(false);
const finalProps = _.omit(props, [
"size",
"circle",
"bgImg",
"backgroundImage",
"fallbackImageSrc",
"srcLight",
"srcDark",
]);
const interpolatedProps: typeof props = {
...finalProps,
width: width,
height: height,
className: twMerge(
"object-cover",
props.circle && "rounded-full",
props.bgImg || props.backgroundImage
? "absolute top-0 left-0 w-full h-full object-cover z-0"
: "",
"twui-img",
props.className,
),
onError: (e) => {
if (props.fallbackImageSrc) {
e.currentTarget.src = props.fallbackImageSrc;
}
props.onError?.(e);
},
style: {
...(props.size
? {
width: `${props.size}px`,
minWidth: `${props.size}px`,
height: `${props.size}px`,
}
: {}),
...props.style,
},
};
useEffect(() => {
if (checkImageFn) {
(async () => {
const does_image_exist = await checkImageFn();
if (!does_image_exist) {
setImageError(true);
}
})();
}
}, []);
if (imageError) {
return (
imgErrComp || (
<img
loading="lazy"
{...interpolatedProps}
src={
imgErrSrc ||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
}
/>
)
);
}
if (props.srcDark && props.srcLight) {
return (
<React.Fragment>
<img
loading="lazy"
{...interpolatedProps}
className={twMerge(
"hidden dark:block",
interpolatedProps.className,
)}
src={props.srcDark}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
<img
loading="lazy"
{...interpolatedProps}
className={twMerge(
"block dark:hidden",
interpolatedProps.className,
)}
src={props.srcLight}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
</React.Fragment>
);
}
return (
<img
loading="lazy"
decoding="async"
{...interpolatedProps}
onError={(e) => {
setImageError(true);
props.onError?.(e);
}}
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
import type {
AnchorHTMLAttributes,
DetailedHTMLProps,
RefAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
import { ArrowUpRight, type LucideProps } from "lucide-react";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
showArrow?: boolean;
arrowSize?: number;
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
strict?: boolean;
};
/**
* # General Anchor Elements
* @className twui-a | twui-anchor
* @info use `cancel-link` class name to prevent triggering this link from a child element
*/
export default function Link({
showArrow,
arrowSize = 20,
arrowProps,
strict,
...props
}: Props) {
return (
<a
{...props}
className={twMerge(
"text-link-500 no-underline hover:text-link-500/50",
"text-link dark:text-link-dark hover:opacity-80 transition-all",
"border-0 border-b border-link dark:border-link-dark border-solid leading-4",
"twui-anchor",
"twui-a",
props.className,
)}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest(".cancel-link")) {
e.preventDefault();
}
props?.onClick?.(e);
}}
data-strict={strict ? "yes" : undefined}
>
{props.children}
{showArrow && (
<ArrowUpRight
size={arrowSize}
{...arrowProps}
className={twMerge(
"inline-block ml-1 -mt-[1px]",
arrowProps?.className,
)}
/>
)}
</a>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type {
AnchorHTMLAttributes,
DetailedHTMLProps,
HTMLAttributes,
OlHTMLAttributes,
} from "react";
import { twMerge } from "tailwind-merge";
/**
* # Ordered and unorder Lists
* @className twui-ol
* @className twui-ul
*/
export default function List({
ordered,
items,
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLUListElement>, HTMLUListElement> &
DetailedHTMLProps<OlHTMLAttributes<HTMLOListElement>, HTMLOListElement> & {
items: {
content: React.ReactNode | string;
}[];
ordered?: boolean;
}) {
const listContent = (
<>
{items.map((item, index) => (
<li key={index}>{item.content}</li>
))}
</>
);
const className = "flex flex-col items-start gap-4";
if (ordered) {
return (
<ol
{...props}
className={twMerge(className, "twui-ol", props.className)}
>
{listContent}
</ol>
);
}
return (
<ul
{...props}
className={twMerge(className, "twui-ul", props.className)}
>
{listContent}
</ul>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
export type LoadingRectangleBlockProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
/**
* # A loading Rectangle block
* @className twui-loading-rectangle-block
* @className twui-loading-block
*/
export default function LoadingRectangleBlock({
...props
}: LoadingRectangleBlockProps) {
return (
<div
{...props}
className={twMerge(
"flex items-center w-full h-10 animate-pulse bg-slate-200 rounded",
"dark:bg-slate-800",
"twui-loading-rectangle-block twui-loading-block",
props.className,
)}
>
{props.children}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # Main Wrapper
* @className twui-h1
*/
export default function Main({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
return (
<main
{...props}
className={twMerge(
"flex flex-col items-center w-full",
"twui-main",
props.className,
)}
>
{props.children}
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLHeadingElement>,
HTMLHeadingElement
> & {
noMargin?: boolean;
};
/**
* # Paragraph Tag
* @className twui-p | twui-paragraph
*/
export default function P({ noMargin, ...props }: Props) {
return (
<p
{...props}
className={twMerge(
"py-4",
noMargin ? "!m-0 p-0" : "",
"twui-p",
"twui-paragraph",
props.className,
)}
>
{props.children}
</p>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
noWrap?: boolean;
itemsStart?: boolean;
};
/**
* # Flexbox Row
* @className twui-row
*/
export default function Row({ noWrap, itemsStart, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"flex flex-row gap-2",
noWrap ? "xl:flex-nowrap" : "flex-wrap",
itemsStart ? "items-start" : "items-center",
"twui-row",
props.className,
)}
>
{props.children}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
/**
* # General Section
* @className twui-section
*/
export default function Section({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) {
return (
<section
{...props}
className={twMerge(
"flex flex-col items-center w-full",
"px-4 sm:px-10 py-10",
"twui-section",
props.className,
)}
>
{props.children}
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import _ from "lodash";
import type { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
horizontal?: boolean;
};
/**
* # Space Component
* @className twui-spacer
*/
export default function Spacer({ horizontal, ...props }: Props) {
return (
<div
{...props}
className={twMerge(
"",
horizontal ? "w-10" : "w-full h-10",
"twui-spacer",
props.className,
)}
>
{props.children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More