This commit is contained in:
Benjamin Toby 2025-03-27 07:37:16 +01:00
parent 9dd6c3a70e
commit 8762e2da8d
23 changed files with 968 additions and 88 deletions

View File

@ -0,0 +1,38 @@
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { 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("py-10 hidden xl:flex", props.className)}
>
<Stack>
{before}
{DocsLinks.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
autoExpandAll={autoExpandAll}
/>
))}
{after}
</Stack>
</aside>
);
}

View File

@ -0,0 +1,121 @@
import React, {
AnchorHTMLAttributes,
ComponentProps,
DetailedHTMLProps,
} from "react";
import { DocsLinkType } from ".";
import Stack from "../../layout/Stack";
import { twMerge } from "tailwind-merge";
import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import { ChevronDown } from "lucide-react";
import Button from "../../layout/Button";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
docLink: DocsLinkType;
wrapperProps?: ComponentProps<typeof Stack>;
strict?: boolean;
childWrapperProps?: ComponentProps<typeof Stack>;
autoExpandAll?: 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,
...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">
<a
href={docLink.href}
{...props}
className={twMerge(
"twui-docs-left-aside-link whitespace-nowrap",
"grow",
isActive ? "active" : "",
props.className
)}
ref={linkRef}
>
{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)}
>
<ChevronDown className="text-slate-500" size={20} />
</Button>
)}
</Row>
{docLink.children && expand && (
<Row className="items-stretch gap-4 grow w-full flex-nowrap">
<Divider vertical className="h-auto" />
<Stack
className={twMerge(
"gap-2 w-full",
childWrapperProps?.className
)}
{...childWrapperProps}
>
{docLink.children.map((link, index) => (
<TWUIDocsLink
docLink={link}
key={index}
className="text-sm"
/>
))}
</Stack>
</Row>
)}
</Stack>
);
}

View File

@ -0,0 +1,76 @@
import {
ComponentProps,
DetailedHTMLProps,
HTMLAttributes,
PropsWithChildren,
} from "react";
import Stack from "../../layout/Stack";
import Container from "../../layout/Container";
import Row from "../../layout/Row";
import Divider from "../../layout/Divider";
import TWUIDocsAside from "./TWUIDocsAside";
import { twMerge } from "tailwind-merge";
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;
};
export type DocsLinkType = {
title: string;
href: string;
children?: DocsLinkType[];
};
/**
* # TWUI Docs
* @className `twui-docs-content`
*/
export default function TWUIDocs({
children,
DocsLinks,
docsAsideAfter,
docsAsideBefore,
wrapperProps,
docsContentProps,
leftAsideProps,
autoExpandAll,
}: Props) {
return (
<Stack
center
{...wrapperProps}
className={twMerge("w-full px-4 sm:px-6", wrapperProps?.className)}
>
<Container>
<Row
{...docsContentProps}
className={twMerge(
"items-stretch gap-6 w-full flex-nowrap",
docsContentProps?.className
)}
>
<TWUIDocsAside
DocsLinks={DocsLinks}
after={docsAsideAfter}
before={docsAsideBefore}
autoExpandAll={autoExpandAll}
{...leftAsideProps}
/>
<Divider vertical className="h-auto hidden xl:flex" />
<div className="block twui-docs-content py-10 pl-0 xl:pl-6 grow">
{children}
</div>
</Row>
</Container>
</Stack>
);
}

View File

@ -3,13 +3,24 @@ 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";
type LinkObject = {
title: string;
path: string;
};
export default function Breadcrumbs() {
type Props = {
excludeRegexMatch?: RegExp;
};
/**
* # TWUI Breadcrumbs
* @className `twui-current-breadcrumb-link`
* @className `twui-current-breadcrumb-wrapper`
*/
export default function Breadcrumbs({ excludeRegexMatch }: Props) {
const [links, setLinks] = React.useState<LinkObject[] | null>(null);
const [current, setCurrent] = React.useState(false);
React.useEffect(() => {
let pathname = window.location.pathname;
@ -27,6 +38,8 @@ export default function Breadcrumbs() {
return;
}
if (excludeRegexMatch && excludeRegexMatch.test(linkText)) return;
validPathLinks.push({
title: lowerToTitleCase(linkText),
path: (() => {
@ -56,30 +69,50 @@ export default function Breadcrumbs() {
}
return (
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
{links.map((linkObject, index, array) => {
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
className="text-slate-400 dark:text-slate-500 pointer-events-none text-xs"
>
{linkObject.title}
</Link>
);
} else {
return (
<React.Fragment key={index}>
<Link href={linkObject.path} className="text-xs">
<div
className={twMerge(
"overflow-x-auto max-w-[70vw]",
"twui-current-breadcrumb-wrapper"
)}
>
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
{links.map((linkObject, index, array) => {
const isTarget = array.length - 1 == index;
if (index === links.length - 1) {
return (
<Link
key={index}
href={linkObject.path}
className={twMerge(
"text-slate-400 dark:text-slate-500 pointer-events-none text-xs",
isTarget ? "current" : "",
"twui-current-breadcrumb-link"
)}
>
{linkObject.title}
</Link>
<Divider vertical />
</React.Fragment>
);
}
})}
</Row>
);
} else {
return (
<React.Fragment key={index}>
<Link
href={linkObject.path}
className={twMerge(
"text-xs",
isTarget ? "current" : "",
"twui-current-breadcrumb-link"
)}
>
{linkObject.title}
</Link>
<Divider vertical />
</React.Fragment>
);
}
})}
</Row>
</div>
);
////////////////////////////////////////
////////////////////////////////////////

View File

@ -0,0 +1,151 @@
import { Check, Copy } from "lucide-react";
import React, {
DetailedHTMLProps,
HTMLAttributes,
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>();
const [copied, setCopied] = React.useState(false);
const title = props?.["data-title"];
const finalBackgroundColor = backgroundColor || "#28272b";
return (
<div
{...wrapperProps}
className={twMerge(
"outline outline-[1px] outline-slate-200 dark:outline-white/10",
`rounded w-full transition-all items-start`,
"relative",
"twui-code-block-wrapper",
wrapperProps?.className
)}
style={{
boxShadow: copied
? "0 0 10px 10px rgba(18, 139, 99, 0.2)"
: undefined,
maxWidth: "calc(100vw - 80px)",
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"
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",
language ? `language-${language}` : "",
"twui-code-block-pre",
props.className
)}
>
{children}
</pre>
</div>
</Stack>
</div>
);
}

View File

@ -37,6 +37,8 @@ let timeout: any;
* @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,
@ -102,7 +104,9 @@ export default function Dropdown({
ref={dropdownRef}
>
<div
onClick={() => {
onClick={(e) => {
const targetEl = e.target as HTMLElement | null;
if (targetEl?.closest?.(".cancel-link")) return;
externalSetOpen?.(!open);
setOpen(!open);
}}

View File

@ -0,0 +1,24 @@
import React, { DetailedHTMLProps, 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>
);
}

View File

@ -8,7 +8,7 @@ import Span from "../layout/Span";
export const ToastStyles = ["normal", "success", "error"] as const;
export const ToastColors = ToastStyles;
type Props = DetailedHTMLProps<
export type TWUIToastProps = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
@ -18,6 +18,9 @@ type Props = DetailedHTMLProps<
color?: (typeof ToastStyles)[number];
};
let interval: any;
let timeout: any;
/**
* # Toast Component
* @className twui-toast-root
@ -31,7 +34,7 @@ export default function Toast({
closeDelay = 4000,
color,
...props
}: Props) {
}: TWUIToastProps) {
if (!open) return null;
const toastEl = (
@ -47,10 +50,25 @@ export default function Toast({
props.className,
"twui-toast"
)}
onMouseEnter={() => {
window.clearTimeout(timeout);
}}
onMouseLeave={(e) => {
const targetEl = e.target as HTMLElement;
const rootWrapperEl = targetEl.closest(
".twui-toast-root"
) as HTMLDivElement | null;
timeout = setTimeout(() => {
closeToast({ wrapperEl: rootWrapperEl });
setOpen?.(false);
}, closeDelay);
}}
>
<Span
className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer"
"absolute top-2 right-2 z-[100] cursor-pointer",
"text-white"
)}
onClick={(e) => {
const targetEl = e.target as HTMLElement;
@ -64,7 +82,7 @@ export default function Toast({
>
<X size={15} />
</Span>
{props.children}
<Span className={twMerge("text-white")}>{props.children}</Span>
</Card>
);
@ -81,7 +99,7 @@ export default function Toast({
const root = createRoot(wrapperEl);
root.render(toastEl);
setTimeout(() => {
timeout = setTimeout(() => {
closeToast({ wrapperEl });
setOpen?.(false);
}, closeDelay);

View File

@ -0,0 +1,164 @@
import Button from "../layout/Button";
import Stack from "../layout/Stack";
import {
File,
FileArchive,
FilePlus,
FilePlus2,
ImagePlus,
X,
} from "lucide-react";
import React, { DetailedHTMLProps } from "react";
import Card from "../elements/Card";
import Span from "../layout/Span";
import Center from "../layout/Center";
import imageInputToBase64, {
FileInputToBase64FunctionReturn,
} from "../utils/form/fileInputToBase64";
import { twMerge } from "tailwind-merge";
import fileInputToBase64 from "../utils/form/fileInputToBase64";
import Row from "../layout/Row";
type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChangeHandler?: (
imgData: FileInputToBase64FunctionReturn | 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;
allowedRegex?: RegExp;
externalSetFile?: React.Dispatch<
React.SetStateAction<FileInputToBase64FunctionReturn | undefined>
>;
};
/**
* @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,
...props
}: ImageUploadProps) {
const [file, setFile] = React.useState<
FileInputToBase64FunctionReturn | undefined
>(undefined);
const inputRef = React.useRef<HTMLInputElement>();
return (
<Stack
{...props}
className={twMerge("w-full h-[300px]", props?.className)}
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
onChange={(e) => {
const inputFile = e.target.files?.[0];
if (!inputFile) return;
fileInputToBase64({ inputFile, allowedRegex }).then(
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
fileInputProps?.onChange?.(e);
}
);
}}
ref={inputRef as any}
/>
{file ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
>
{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}
/>
) : (
<Row>
<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>
</Row>
)}
<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);
}}
>
<X className="text-slate-950 dark:text-white" />
</Button>
</Card>
) : (
<Card
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className
)}
onClick={(e) => {
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
<FilePlus2 className="text-slate-400" />
<Span size="smaller" variant="faded">
{label || "Click to Upload File"}
</Span>
</Stack>
</Center>
</Card>
)}
</Stack>
);
}

View File

@ -135,15 +135,7 @@ export default function Input<KeyType extends string>({
...props
}: InputProps<KeyType>) {
const [focus, setFocus] = React.useState(false);
const [value, setValue] = React.useState(
props.value
? String(props.value)
: props.defaultValue
? String(props.defaultValue)
: ""
);
delete props.defaultValue;
const [value, setValue] = React.useState(props.defaultValue || props.value);
const [isValid, setIsValid] = React.useState(true);
@ -151,27 +143,28 @@ export default function Input<KeyType extends string>({
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
React.useEffect(() => {
if (!value.match(/./)) return setIsValid(true);
window.clearTimeout(timeout);
if (typeof value == "string") {
if (!value.match(/./)) return setIsValid(true);
window.clearTimeout(timeout);
if (validationRegex) {
timeout = setTimeout(() => {
setIsValid(validationRegex.test(value));
}, finalDebounce);
}
if (validationRegex) {
timeout = setTimeout(() => {
setIsValid(validationRegex.test(value));
}, finalDebounce);
}
if (validationFunction) {
timeout = setTimeout(() => {
validationFunction(value).then((res) => {
setIsValid(res);
});
}, finalDebounce);
if (validationFunction) {
timeout = setTimeout(() => {
validationFunction(value).then((res) => {
setIsValid(res);
});
}, finalDebounce);
}
}
}, [value]);
React.useEffect(() => {
if (!props.value) return;
setValue(String(props.value));
setValue(props.value || "");
}, [props.value]);
const targetComponent = istextarea ? (
@ -192,7 +185,10 @@ export default function Input<KeyType extends string>({
props?.onBlur?.(e);
}}
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
setValue(e.target.value);
props?.onChange?.(e);
}}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : 4}
/>

View File

@ -89,7 +89,7 @@ export default function Select({
props.className
)}
ref={componentRef}
defaultValue={
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}

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

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

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

View File

@ -10,10 +10,10 @@ let reconnectInterval: any;
let msgInterval: any;
let sendInterval: any;
let tries = 0;
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
let tries = 0;
/**
* # Use Websocket Hook
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
@ -38,10 +38,6 @@ export default function useWebSocket<
const messageQueueRef = React.useRef<string[]>([]);
const sendMessageQueueRef = React.useRef<string[]>([]);
// const [message, setMessage] = React.useState<string>("");
// const [data, setData] = React.useState<T | null>(null);
const [refresh, setRefresh] = React.useState(0);
const dispatchCustomEvent = React.useCallback(
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
const event = new CustomEvent(evtName, {
@ -55,16 +51,17 @@ export default function useWebSocket<
[]
);
React.useEffect(() => {
const connect = React.useCallback(() => {
const wsURL = url;
if (!wsURL) return;
const ws = new WebSocket(wsURL);
let ws = new WebSocket(wsURL);
ws.onopen = (ev) => {
window.clearInterval(reconnectInterval);
setSocket(ws);
tries = 0;
console.log(`Websocket connected to ${wsURL}`);
};
ws.onmessage = (ev) => {
@ -83,16 +80,22 @@ export default function useWebSocket<
if (tries >= 3) {
return window.clearInterval(reconnectInterval);
}
console.log("Attempting to reconnect ...");
setRefresh(refresh + 1);
tries++;
connect();
}, 1000);
};
}, []);
React.useEffect(() => {
connect();
return function () {
window.clearInterval(reconnectInterval);
};
}, [refresh]);
}, []);
/**
* Received Message Queue Handler
@ -103,8 +106,6 @@ export default function useWebSocket<
if (!newMessage) return;
try {
const jsonData = JSON.parse(newMessage);
// setData(jsonData);
dispatchCustomEvent("wsMessageEvent", newMessage);
dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) {

View File

@ -119,26 +119,26 @@ export default function Button({
} else if (variant == "ghost") {
if (color == "primary" || !color)
return twMerge(
"bg-transparent outline-none p-2",
"text-blue-500",
"bg-transparent dark:bg-transparent outline-none p-2",
"text-blue-500 hover:bg-transparent dark:hover:bg-transparent",
"twui-button-primary-ghost"
);
if (color == "secondary")
return twMerge(
"bg-transparent outline-none p-2",
"text-emerald-500",
"bg-transparent dark:bg-transparent outline-none p-2",
"text-emerald-500 hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost"
);
if (color == "accent")
return twMerge(
"bg-transparent outline-none p-2",
"text-violet-500",
"bg-transparent dark:bg-transparent outline-none p-2",
"text-violet-500 hover:bg-transparent dark:hover:bg-transparent",
"twui-button-accent-ghost"
);
if (color == "gray")
return twMerge(
"bg-transparent outline-none p-2",
"text-slate-600 dark:text-white/70",
"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"
);
if (color == "error")

View File

@ -17,7 +17,7 @@ export default function Divider({
<div
{...props}
className={twMerge(
"border-slate-200 dark:border-white/10",
"border-slate-200 dark:border-white/10 border-solid",
vertical
? "border-0 border-l h-full min-h-5"
: "border-0 border-t w-full",

View File

@ -1,16 +1,26 @@
import { AnchorHTMLAttributes, DetailedHTMLProps } from "react";
import { AnchorHTMLAttributes, DetailedHTMLProps, RefAttributes } from "react";
import { twMerge } from "tailwind-merge";
import { ArrowUpRight, LucideProps } from "lucide-react";
type Props = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
> & {
showArrow?: boolean;
arrowSize?: number;
arrowProps?: Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>;
};
/**
* # General Anchor Elements
* @className twui-a | twui-anchor
*/
export default function Link({
showArrow,
arrowSize = 20,
arrowProps,
...props
}: DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>) {
}: Props) {
return (
<a
{...props}
@ -18,13 +28,22 @@ export default function Link({
"text-base text-link-500 no-underline hover:text-link-500/50",
"text-blue-600 dark:text-blue-400 hover:opacity-60 transition-all",
"border-0 border-b border-blue-300 dark:border-blue-200/30 border-solid leading-4",
// "focus:text-red-600",
"twui-anchor",
"twui-a",
props.className
)}
>
{props.children}
{showArrow && (
<ArrowUpRight
size={arrowSize}
{...arrowProps}
className={twMerge(
"inline-block ml-1 -mt-[1px]",
arrowProps?.className
)}
/>
)}
</a>
);
}

View File

@ -2,15 +2,18 @@ import _ from "lodash";
import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
center?: boolean;
};
/**
* # Flexbox Column
* @className twui-stack
*/
export default function Stack({
...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
center?: boolean;
}) {
export default function Stack({ ...props }: Props) {
const finalProps = _.omit(props, "center");
return (
<div

View File

@ -0,0 +1,39 @@
import React from "react";
import type { MDXComponents } from "mdx/types";
import H1 from "../../layout/H1";
import H2 from "../../layout/H2";
import H3 from "../../layout/H3";
import H4 from "../../layout/H4";
import CodeBlock from "../../elements/CodeBlock";
type Params = {
components: MDXComponents;
codeBgColor?: string;
};
export default function useMDXComponents({
components,
codeBgColor,
}: Params): MDXComponents {
return {
h1: ({ children }) => <H1>{children}</H1>,
h2: ({ children }) => <H2>{children}</H2>,
h3: ({ children }) => <H3>{children}</H3>,
h4: ({ children }) => <H4>{children}</H4>,
pre: ({ children, ...props }) => {
if (React.isValidElement(children) && children.props) {
return (
<CodeBlock {...props} backgroundColor={codeBgColor}>
{children.props.children}
</CodeBlock>
);
}
return (
<CodeBlock {...props} backgroundColor={codeBgColor}>
{children}
</CodeBlock>
);
},
...components,
};
}

View File

@ -0,0 +1,27 @@
{
"name": "tailwind-ui",
"type": "module",
"dependencies": {
"@xterm/xterm": "^5.5.0",
"lodash": "^4.17.21",
"lucide-react": "^0.453.0",
"react": "^19.0.0",
"react-code-blocks": "^0.1.6",
"react-dom": "^19.0.0",
"react-responsive-modal": "^6.4.2",
"tailwind-merge": "^2.6.0",
"typescript": "^5.7.3"
},
"devDependencies": {
"@types/ace": "^0.0.52",
"@types/bun": "latest",
"@types/lodash": "^4.17.15",
"@types/node": "^20.17.16",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"@types/mdx": "^2.0.13",
"@next/mdx": "^15.1.5"
}
}

View File

@ -0,0 +1,59 @@
export type FileInputToBase64FunctionReturn = {
fileBase64?: string;
fileBase64Full?: string;
fileName?: string;
fileSize?: number;
fileType?: string;
file?: File;
};
export type FileInputToBase64FunctioParam = {
inputFile: File;
allowedRegex?: RegExp;
};
export default async function fileInputToBase64({
inputFile,
allowedRegex,
}: FileInputToBase64FunctioParam): Promise<FileInputToBase64FunctionReturn> {
const allowedTypesRegex = allowedRegex ? allowedRegex : undefined;
if (allowedTypesRegex && !inputFile?.type?.match(allowedTypesRegex)) {
window.alert(`We currently don't support ${inputFile.type} file type.`);
return { fileName: inputFile.name };
}
let fileName = inputFile.name?.replace(/\..*/, "");
const file = inputFile;
try {
const fileData: string | undefined = await new Promise(
(resolve, reject) => {
var reader = new FileReader();
reader.readAsDataURL(inputFile);
reader.onload = function () {
resolve(reader.result?.toString());
};
reader.onerror = function (/** @type {*} */ error: any) {
console.log("Error: ", error.message);
};
}
);
return {
fileBase64: fileData?.replace(/.*?base64,/, ""),
fileBase64Full: fileData,
fileName: fileName,
fileSize: inputFile.size,
fileType: inputFile.type,
file,
};
} catch (error: any) {
console.log("File Processing Error! =>", error.message);
return {
fileName: inputFile.name,
file,
};
}
}

View File

@ -59,7 +59,7 @@ export const SocialLinks: SocialLinksType[] = [
},
{
name: "Git",
href: "https://git.tben.me",
href: "https://git.tben.me/explore/repos",
icon: <GitBranch size={17} />,
},
{