This commit is contained in:
Benjamin Toby 2026-02-13 19:04:07 +01:00
parent 9505331165
commit 69d432b6af
42 changed files with 650 additions and 360 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
dsql-schema-to-typedef.json

BIN
bun.lockb Normal file → Executable file

Binary file not shown.

View File

@ -40,7 +40,7 @@ export default function ModalComponent({ open, setOpen, ...props }: Props) {
<Paper <Paper
{..._.omit(props, ["targetWrapperProps"])} {..._.omit(props, ["targetWrapperProps"])}
className={twMerge( className={twMerge(
"z-10 max-w-[500px] bg-background-light dark:bg-background-dark", "z-10 max-w-modal bg-background-light dark:bg-background-dark",
"w-full relative max-h-[95vh] overflow-y-auto", "w-full relative max-h-[95vh] overflow-y-auto",
"twui-modal-content", "twui-modal-content",
props.className props.className

View File

@ -62,6 +62,7 @@
--radius-default-xl: 10px; --radius-default-xl: 10px;
--container-container: 1200px; --container-container: 1200px;
--container-modal: 800px;
} }
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));

View File

@ -53,8 +53,6 @@ export default function TWUIDocsRightAside({
const nextElementH3 = nextElement.querySelector("h3"); const nextElementH3 = nextElement.querySelector("h3");
console.log("nextElement", nextElement);
const isNextElementH2 = const isNextElementH2 =
nextElement.querySelector("h2") !== null; nextElement.querySelector("h2") !== null;

View File

@ -1,14 +1,16 @@
import React, { MutableRefObject } from "react"; import React, { MutableRefObject } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import AceEditorModes from "./ace-editor-modes"; import AceEditorModes from "./ace-editor-modes";
import { AceEditorOptions } from "@moduletrace/datasquirel/dist/package-shared/types";
export type AceEditorComponentType = { export type AceEditorComponentType = {
editorRef?: MutableRefObject<AceAjax.Editor>; editorRef?: MutableRefObject<AceAjax.Editor | undefined>;
readOnly?: boolean; readOnly?: boolean;
/** Function to call when Ctrl+Enter is pressed */ /** Function to call when Ctrl+Enter is pressed */
ctrlEnterFn?: (editor: AceAjax.Editor) => void; ctrlEnterFn?: (editor: AceAjax.Editor) => void;
content?: string; content?: string;
placeholder?: string; placeholder?: string;
title?: string;
mode?: (typeof AceEditorModes)[number]; mode?: (typeof AceEditorModes)[number];
fontSize?: string; fontSize?: string;
previewMode?: boolean; previewMode?: boolean;
@ -18,7 +20,9 @@ export type AceEditorComponentType = {
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>; >;
refresh?: number; refreshDepArr?: any[];
editorOptions?: AceEditorOptions;
showLabel?: boolean;
}; };
let timeout: any; let timeout: any;
@ -40,13 +44,15 @@ export default function AceEditor({
previewMode, previewMode,
onChange, onChange,
delay = 500, delay = 500,
refresh: externalRefresh, refreshDepArr,
wrapperProps, wrapperProps,
editorOptions,
showLabel,
title,
}: AceEditorComponentType) { }: AceEditorComponentType) {
try { try {
const editorElementRef = React.useRef<HTMLDivElement>(null); const editorElementRef = React.useRef<HTMLDivElement>(undefined);
// const editorRefInstance = React.useRef<AceAjax.Editor>(null); const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const editorRefInstance = React.useRef<any>(null);
const [refresh, setRefresh] = React.useState(0); const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false); const [darkMode, setDarkMode] = React.useState(false);
@ -84,9 +90,7 @@ export default function AceEditor({
showLineNumbers: previewMode ? false : true, showLineNumbers: previewMode ? false : true,
wrap: true, wrap: true,
wrapMethod: "code", wrapMethod: "code",
// onchange: (e) => { ...editorOptions,
// console.log(e);
// },
}); });
editor.commands.addCommand({ editor.commands.addCommand({
@ -103,7 +107,9 @@ export default function AceEditor({
clearTimeout(timeout); clearTimeout(timeout);
setTimeout(() => { setTimeout(() => {
try {
onChange(editor.getValue()); onChange(editor.getValue());
} catch (error) {}
}, delay); }, delay);
} }
}); });
@ -114,7 +120,7 @@ export default function AceEditor({
return function () { return function () {
editor.destroy(); editor.destroy();
}; };
}, [refresh, darkMode, ready, externalRefresh]); }, [refresh, darkMode, ready, mode, ...(refreshDepArr || [])]);
React.useEffect(() => { React.useEffect(() => {
const htmlClassName = document.documentElement.className; const htmlClassName = document.documentElement.className;
@ -129,12 +135,23 @@ export default function AceEditor({
<div <div
{...wrapperProps} {...wrapperProps}
className={twMerge( className={twMerge(
"w-full h-[400px] block rounded-default overflow-hidden", "w-full h-[400px] block rounded-default",
"border border-slate-200 border-solid", "border border-slate-200 border-solid relative",
"dark:border-white/20", "dark:border-white/20",
wrapperProps?.className 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 <div
ref={editorElementRef as any} ref={editorElementRef as any}
className="w-full h-full" className="w-full h-full"

View File

@ -13,6 +13,7 @@ import Button from "../layout/Button";
export type TWUI_LINK_LIST_LINK_OBJECT = { export type TWUI_LINK_LIST_LINK_OBJECT = {
title?: string; title?: string;
component?: ReactNode;
url?: string; url?: string;
strict?: boolean; strict?: boolean;
icon?: ReactNode; icon?: ReactNode;
@ -70,7 +71,7 @@ export default function LinkList({
className={twMerge( className={twMerge(
"flex flex-row items-center gap-1", "flex flex-row items-center gap-1",
"twui-link-list", "twui-link-list",
props.className props.className,
)} )}
> >
{links {links
@ -104,7 +105,7 @@ export default function LinkList({
{...link.buttonProps} {...link.buttonProps}
className={twMerge( className={twMerge(
"p-2 cursor-pointer whitespace-nowrap", "p-2 cursor-pointer whitespace-nowrap",
linkProps?.className linkProps?.className,
)} )}
onClick={(e) => { onClick={(e) => {
link.onClick?.(e); link.onClick?.(e);
@ -113,7 +114,7 @@ export default function LinkList({
> >
<Row> <Row>
{link.icon} {link.icon}
{link.title} {link.component || link.title}
</Row> </Row>
</Button> </Button>
{finalDivider} {finalDivider}
@ -131,7 +132,7 @@ export default function LinkList({
className={twMerge( className={twMerge(
"p-2 cursor-pointer whitespace-nowrap", "p-2 cursor-pointer whitespace-nowrap",
linkProps?.className, linkProps?.className,
link.linkProps?.className link.linkProps?.className,
)} )}
strict={link.strict} strict={link.strict}
onClick={(e) => { onClick={(e) => {
@ -144,7 +145,7 @@ export default function LinkList({
link.iconPosition == "before" link.iconPosition == "before"
? link.icon ? link.icon
: null} : null}
{link.title} {link.component || link.title}
{link.iconPosition == "after" {link.iconPosition == "after"
? link.icon ? link.icon
: null} : null}

View File

@ -31,14 +31,18 @@ export default function Loading({ size, svgClassName, ...props }: Props) {
})(); })();
return ( return (
<div role="status" {...props}> <div
role="status"
{...props}
className={twMerge(`twui-loading`, props.className)}
>
<svg <svg
aria-hidden="true" aria-hidden="true"
className={twMerge( className={twMerge(
"text-gray animate-spin dark:text-gray-dark fill-primary", "text-gray animate-spin dark:text-gray-dark fill-primary",
"dark:fill-white twui-loading", "dark:fill-white twui-loading",
sizeClassName, sizeClassName,
svgClassName svgClassName,
)} )}
viewBox="0 0 100 101" viewBox="0 0 100 101"
fill="none" fill="none"

View File

@ -11,6 +11,7 @@ type Props = DetailedHTMLProps<
> & { > & {
loadingProps?: ComponentProps<typeof Loading>; loadingProps?: ComponentProps<typeof Loading>;
label?: string; label?: string;
fixed?: boolean;
}; };
/** /**
@ -20,16 +21,18 @@ type Props = DetailedHTMLProps<
export default function LoadingOverlay({ export default function LoadingOverlay({
loadingProps, loadingProps,
label, label,
fixed,
...props ...props
}: Props) { }: Props) {
return ( return (
<div <div
{...props} {...props}
className={twMerge( className={twMerge(
"absolute top-0 left-0 w-full h-full z-[500]", "top-0 left-0 w-full h-full z-[500]",
"bg-background-light/90 dark:bg-background-dark/90", "bg-background-light/90 dark:bg-background-dark/90",
fixed ? "fixed" : "absolute",
props.className, props.className,
"twui-loading-overlay" "twui-loading-overlay",
)} )}
> >
<Center> <Center>

View File

@ -146,7 +146,11 @@ export default function Modal(props: TWUI_MODAL_PROPS) {
{target ? ( {target ? (
<div <div
{...targetWrapperProps} {...targetWrapperProps}
onClick={(e) => setOpen(!open)} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(!open);
}}
ref={finalTargetRef} ref={finalTargetRef}
onMouseEnter={ onMouseEnter={
isPopover && (trigger === "hover" || hoverOpen) isPopover && (trigger === "hover" || hoverOpen)

View File

@ -9,6 +9,7 @@ export const TWUIPrismLanguages = ["shell", "javascript"] as const;
type Props = { type Props = {
content: string; content: string;
refresh?: number;
}; };
/** /**
@ -16,7 +17,7 @@ type Props = {
* *
* @className `twui-remote-code-block-wrapper` * @className `twui-remote-code-block-wrapper`
*/ */
export default function RemoteCodeBlock({ content }: Props) { export default function RemoteCodeBlock({ content, refresh }: Props) {
const [mdxSource, setMdxSource] = const [mdxSource, setMdxSource] =
React.useState<MDXRemoteSerializeResult<any>>(); React.useState<MDXRemoteSerializeResult<any>>();
@ -31,7 +32,7 @@ export default function RemoteCodeBlock({ content }: Props) {
}).then((mdxSrc) => { }).then((mdxSrc) => {
setMdxSource(mdxSrc); setMdxSource(mdxSrc);
}); });
}, []); }, [refresh]);
if (!mdxSource) { if (!mdxSource) {
return null; return null;

View File

@ -78,12 +78,12 @@ export default function Search<KeyType extends string>({
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
className={twMerge( className={twMerge(
"rounded-r-none", "rounded-r-none!",
"twui-search-input", "twui-search-input",
inputProps?.className inputProps?.className
)} )}
wrapperProps={{ wrapperProps={{
className: "rounded-r-none", className: "rounded-r-none!",
}} }}
componentRef={inputRef} componentRef={inputRef}
/> />
@ -93,7 +93,7 @@ export default function Search<KeyType extends string>({
variant="outlined" variant="outlined"
color="gray" color="gray"
className={twMerge( className={twMerge(
"rounded-l-none ml-[1px]", "rounded-l-none! ml-[1px]",
"twui-search-button", "twui-search-button",
buttonProps?.className buttonProps?.className
)} )}

View File

@ -14,6 +14,7 @@ type StarProps = {
starProps?: LucideProps; starProps?: LucideProps;
allowRating?: boolean; allowRating?: boolean;
setValueExternal?: React.Dispatch<React.SetStateAction<number>>; setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
changeHandler?: (value: number) => void;
}; };
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps< export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
@ -35,6 +36,7 @@ export default function StarRating({
starProps, starProps,
allowRating, allowRating,
setValueExternal, setValueExternal,
changeHandler,
...props ...props
}: TWUI_STAR_RATING_PROPS) { }: TWUI_STAR_RATING_PROPS) {
const totalArray = Array(total).fill(null); const totalArray = Array(total).fill(null);
@ -58,7 +60,7 @@ export default function StarRating({
className={twMerge( className={twMerge(
"flex flex-row items-center gap-0 -ml-[2px]", "flex flex-row items-center gap-0 -ml-[2px]",
"twui-star-rating", "twui-star-rating",
props.className props.className,
)} )}
onMouseEnter={() => { onMouseEnter={() => {
sectionHovered.current = true; sectionHovered.current = true;
@ -68,6 +70,8 @@ export default function StarRating({
}} }}
> >
{totalArray.map((_, index) => { {totalArray.map((_, index) => {
const isActive = index + 1 <= finalValue;
return ( return (
<StarComponent <StarComponent
{...{ {...{
@ -83,6 +87,8 @@ export default function StarRating({
selectedStarValue, selectedStarValue,
sectionHovered, sectionHovered,
setSelectedStarValue, setSelectedStarValue,
isActive,
changeHandler,
}} }}
key={index} key={index}
/> />
@ -93,34 +99,31 @@ export default function StarRating({
} }
function StarComponent({ function StarComponent({
value = 0,
size = 20, size = 20,
starProps, starProps,
index, index,
allowRating, allowRating,
finalValue,
setFinalValue, setFinalValue,
starClicked, starClicked,
sectionHovered, sectionHovered,
setSelectedStarValue, setSelectedStarValue,
selectedStarValue, selectedStarValue,
isActive,
changeHandler,
}: StarProps & { }: StarProps & {
index: number; index: number;
finalValue: number;
setFinalValue: React.Dispatch<React.SetStateAction<number>>; setFinalValue: React.Dispatch<React.SetStateAction<number>>;
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>; setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
starClicked: React.MutableRefObject<boolean>; starClicked: React.MutableRefObject<boolean>;
sectionHovered: React.MutableRefObject<boolean>; sectionHovered: React.MutableRefObject<boolean>;
selectedStarValue: number; selectedStarValue: number;
isActive: boolean;
}) { }) {
const isActive = index < finalValue;
return ( return (
<div <div
className={twMerge("p-[2px]", allowRating && "cursor-pointer")} className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
onMouseEnter={() => { onMouseEnter={() => {
if (!allowRating) return; if (!allowRating) return;
setFinalValue(index + 1); setFinalValue(index + 1);
}} }}
onMouseLeave={() => { onMouseLeave={() => {
@ -145,6 +148,7 @@ function StarComponent({
starClicked.current = true; starClicked.current = true;
setSelectedStarValue(index + 1); setSelectedStarValue(index + 1);
changeHandler?.(index + 1);
}} }}
> >
<Star <Star
@ -155,7 +159,7 @@ function StarComponent({
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400", "text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
// allowRating && // allowRating &&
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400", // "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
starProps?.className starProps?.className,
)} )}
{...starProps} {...starProps}
/> />

View File

@ -27,6 +27,8 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
switchComponent?: ReactNode; switchComponent?: ReactNode;
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>; setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
changeHandler?: (value: TWUITabsObject) => void; changeHandler?: (value: TWUITabsObject) => void;
defaultValue?: string | null;
hrefUpdate?: boolean;
}; };
/** /**
@ -47,6 +49,8 @@ export default function Tabs({
switchComponent, switchComponent,
setActiveValue: existingSetActiveValue, setActiveValue: existingSetActiveValue,
changeHandler, changeHandler,
defaultValue,
hrefUpdate,
...props ...props
}: TWUI_TOGGLE_PROPS) { }: TWUI_TOGGLE_PROPS) {
const finalTabsContentArray = tabsContentArray const finalTabsContentArray = tabsContentArray
@ -54,31 +58,60 @@ export default function Tabs({
.filter((ct) => Boolean(ct?.title)) as TWUITabsObject[]; .filter((ct) => Boolean(ct?.title)) as TWUITabsObject[];
const values = finalTabsContentArray.map( const values = finalTabsContentArray.map(
(obj) => obj.value || twuiSlugify(obj.title) (obj) => obj.value || twuiSlugify(obj.title),
); );
const defaultActiveObj = finalTabsContentArray.find( const defaultActiveObj = finalTabsContentArray.find(
(ctn) => ctn.defaultActive (ctn) => ctn.defaultActive,
); );
const [activeValue, setActiveValue] = React.useState( const [activeValue, setActiveValue] = React.useState(
defaultActiveObj defaultValue
? defaultValue
: defaultActiveObj
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title) ? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
: values[0] || undefined : values[0] || undefined,
); );
const [ready, setReady] = React.useState(false);
const targetContent = finalTabsContentArray.find( const targetContent = finalTabsContentArray.find(
(ctn) => (ctn) =>
ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue ctn.value == activeValue || twuiSlugify(ctn.title) == activeValue,
); );
React.useEffect(() => { React.useEffect(() => {
if (!ready) return;
existingSetActiveValue?.(activeValue); existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) { if (targetContent && activeValue) {
changeHandler?.(targetContent); changeHandler?.(targetContent);
if (hrefUpdate) {
const url = new URL(window.location.href);
url.searchParams.set("tab", activeValue);
window.history.pushState({}, "", url);
}
} }
}, [activeValue]); }, [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 ( return (
<Stack <Stack
{...props} {...props}
@ -89,7 +122,7 @@ export default function Tabs({
className={twMerge( className={twMerge(
"w-full", "w-full",
"twui-tab-buttons-wrapper", "twui-tab-buttons-wrapper",
tabsButtonsWrapperProps?.className tabsButtonsWrapperProps?.className,
)} )}
> >
<Border <Border
@ -100,14 +133,14 @@ export default function Tabs({
className={twMerge( className={twMerge(
"gap-0 items-stretch w-full flex-nowrap overflow-x-auto", "gap-0 items-stretch w-full flex-nowrap overflow-x-auto",
centered && "justify-center", centered && "justify-center",
"twui-tab-buttons-container" "twui-tab-buttons-container",
)} )}
> >
{values.map((value, index) => { {values.map((value, index) => {
const targetObject = finalTabsContentArray.find( const targetObject = finalTabsContentArray.find(
(ctn) => (ctn) =>
ctn.value == value || ctn.value == value ||
twuiSlugify(ctn.title) == value twuiSlugify(ctn.title) == value,
); );
const isActive = value == activeValue; const isActive = value == activeValue;
@ -120,7 +153,7 @@ export default function Tabs({
? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active" ? "bg-primary dark:bg-primary-dark text-white outline-none twui-tab-button-active"
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" + : "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
" cursor-pointer", " cursor-pointer",
"twui-tab-buttons" "twui-tab-buttons",
)} )}
onClick={() => { onClick={() => {
setActiveValue(undefined); setActiveValue(undefined);

View File

@ -14,6 +14,7 @@ export type TWUIToastProps = DetailedHTMLProps<
> & { > & {
open?: boolean; open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>; setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
closeDispatch?: (open?: boolean) => void;
closeDelay?: number; closeDelay?: number;
color?: (typeof ToastStyles)[number]; color?: (typeof ToastStyles)[number];
}; };
@ -33,6 +34,7 @@ export default function Toast({
setOpen, setOpen,
closeDelay = 4000, closeDelay = 4000,
color, color,
closeDispatch,
...props ...props
}: TWUIToastProps) { }: TWUIToastProps) {
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
@ -56,10 +58,12 @@ export default function Toast({
timeout = setTimeout(() => { timeout = setTimeout(() => {
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open);
}, closeDelay); }, closeDelay);
return function () { return function () {
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open);
}; };
}, [ready, open]); }, [ready, open]);
@ -73,12 +77,12 @@ export default function Toast({
"fixed bottom-4 right-4 z-[250] border-none", "fixed bottom-4 right-4 z-[250] border-none",
"pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark", "pl-6 pr-8 py-4 bg-primary dark:bg-primary-dark",
color == "success" color == "success"
? "bg-success dark:bg-success-dark twui-toast-success" ? "bg-success-dark dark:bg-success-dark twui-toast-success"
: color == "error" : color == "error"
? "bg-error dark:bg-error-dark twui-toast-error" ? "bg-error dark:bg-error-dark twui-toast-error"
: "", : "",
props.className, props.className,
"twui-toast" "twui-toast",
)} )}
onMouseEnter={() => { onMouseEnter={() => {
window.clearTimeout(timeout); window.clearTimeout(timeout);
@ -86,22 +90,28 @@ export default function Toast({
onMouseLeave={(e) => { onMouseLeave={(e) => {
timeout = setTimeout(() => { timeout = setTimeout(() => {
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open);
}, closeDelay); }, closeDelay);
}} }}
> >
<Span <Span
className={twMerge( className={twMerge(
"absolute top-2 right-2 z-[100] cursor-pointer", "absolute top-2 right-2 z-[100] cursor-pointer",
"text-white" "text-white",
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen?.(false); setOpen?.(false);
closeDispatch?.(open);
}} }}
> >
<X size={15} /> <X size={15} />
</Span> </Span>
<Span className={twMerge("text-white")}>{props.children}</Span> <Span className={twMerge("text-white! font-semibold")}>
{props.children}
</Span>
</Card>, </Card>,
document.getElementById(IDName) as HTMLElement document.getElementById(IDName) as HTMLElement,
); );
} }

View File

@ -32,7 +32,7 @@ export default function AIPromptHistoryModal({ history }: Props) {
View History View History
</Button> </Button>
} }
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-10" className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-8"
> >
<Stack className="gap-10 w-full"> <Stack className="gap-10 w-full">
<Stack className="gap-1"> <Stack className="gap-1">

View File

@ -1,7 +1,7 @@
import Divider from "@/src/components/twui/layout/Divider"; import Divider from "../../layout/Divider";
import Stack from "@/src/components/twui/layout/Stack"; import Stack from "../../layout/Stack";
import React from "react"; import React from "react";
import MarkdownEditorPreviewComponent from "@/src/components/twui/mdx/markdown/MarkdownEditorPreviewComponent"; import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
import { ChatCompletionMessageParam } from "openai/resources/index"; import { ChatCompletionMessageParam } from "openai/resources/index";
type Props = { type Props = {

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

View File

@ -66,7 +66,7 @@ export default function Checkbox({
const finalSize = size || 20; const finalSize = size || 20;
const [checked, setChecked] = React.useState( const [checked, setChecked] = React.useState(
defaultChecked || externalChecked || false defaultChecked || externalChecked || false,
); );
const finalTitle = title const finalTitle = title
@ -93,7 +93,7 @@ export default function Checkbox({
"flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap", "flex items-start md:items-center gap-2 flex-wrap md:flex-nowrap",
readOnly ? "opacity-70 pointer-events-none" : "", readOnly ? "opacity-70 pointer-events-none" : "",
wrapperClassName, wrapperClassName,
wrapperProps?.className wrapperProps?.className,
)} )}
onClick={() => { onClick={() => {
setChecked(!checked); setChecked(!checked);
@ -108,7 +108,7 @@ export default function Checkbox({
? "bg-primary twui-checkbox-checked text-white outline-slate-400" ? "bg-primary twui-checkbox-checked text-white outline-slate-400"
: "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked", : "dark:outline-white/50 outline-2 -outline-offset-2 twui-checkbox-unchecked",
"twui-checkbox", "twui-checkbox",
props.className props.className,
)} )}
style={{ style={{
minWidth: finalSize + "px", minWidth: finalSize + "px",
@ -125,7 +125,7 @@ export default function Checkbox({
{...labelProps} {...labelProps}
className={twMerge( className={twMerge(
"select-none whitespace-normal md:whitespace-nowrap", "select-none whitespace-normal md:whitespace-nowrap",
labelProps?.className labelProps?.className,
)} )}
> >
{label || finalTitle} {label || finalTitle}
@ -134,8 +134,8 @@ export default function Checkbox({
)} )}
</div> </div>
{info && ( {info && (
<Row className="gap-1" title={info.toString()}> <Row className="gap-1 flex-nowrap" title={info.toString()}>
<Info size={12} className="opacity-40" /> <Info size={13} className="opacity-40 min-w-[20px]" />
<Span size="smaller" className="opacity-70"> <Span size="smaller" className="opacity-70">
{info} {info}
</Span> </Span>

View File

@ -1,11 +1,12 @@
import _ from "lodash"; import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes } from "react"; import { DetailedHTMLProps, FormHTMLAttributes, RefObject } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type Props<T extends { [key: string]: any } = { [key: string]: any }> = type Props<T extends { [key: string]: any } = { [key: string]: any }> =
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & { DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void; submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void; changeHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
formRef?: RefObject<HTMLFormElement>;
}; };
/** /**
@ -13,8 +14,8 @@ type Props<T extends { [key: string]: any } = { [key: string]: any }> =
* @className twui-form * @className twui-form
*/ */
export default function Form< export default function Form<
T extends { [key: string]: any } = { [key: string]: any } T extends { [key: string]: any } = { [key: string]: any },
>({ ...props }: Props<T>) { >({ formRef, ...props }: Props<T>) {
const finalProps = _.omit(props, ["submitHandler", "changeHandler"]); const finalProps = _.omit(props, ["submitHandler", "changeHandler"]);
return ( return (
@ -23,7 +24,7 @@ export default function Form<
className={twMerge( className={twMerge(
"flex flex-col items-stretch gap-2 w-full bg-transparent", "flex flex-col items-stretch gap-2 w-full bg-transparent",
"twui-form", "twui-form",
props.className props.className,
)} )}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@ -42,6 +43,7 @@ export default function Form<
props.changeHandler?.(e, data); props.changeHandler?.(e, data);
props.onChange?.(e); props.onChange?.(e);
}} }}
ref={formRef}
> >
{props.children} {props.children}
</form> </form>

View File

@ -10,13 +10,15 @@ import imageInputToBase64, {
} from "../utils/form/imageInputToBase64"; } from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Tag from "../elements/Tag"; import Tag from "../elements/Tag";
import Input from "./Input";
import Row from "../layout/Row";
type ImageUploadProps = DetailedHTMLProps< type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
> & { > & {
onChangeHandler?: ( onChangeHandler?: (
imgData: ImageInputToBase64FunctionReturn | undefined imgData: ImageInputToBase64FunctionReturn | undefined,
) => any; ) => any;
fileInputProps?: DetailedHTMLProps< fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
@ -45,6 +47,7 @@ type ImageUploadProps = DetailedHTMLProps<
React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined> React.SetStateAction<ImageInputToBase64FunctionReturn[] | undefined>
>; >;
setLoading?: React.Dispatch<React.SetStateAction<boolean>>; setLoading?: React.Dispatch<React.SetStateAction<boolean>>;
setImgURL?: React.Dispatch<React.SetStateAction<string | undefined>>;
externalImage?: ImageInputToBase64FunctionReturn; externalImage?: ImageInputToBase64FunctionReturn;
restoreImageFn?: () => void; restoreImageFn?: () => void;
}; };
@ -67,6 +70,7 @@ export default function ImageUpload({
multiple, multiple,
restoreImageFn, restoreImageFn,
setLoading, setLoading,
setImgURL,
...props ...props
}: ImageUploadProps) { }: ImageUploadProps) {
const [imageObject, setImageObject] = React.useState< const [imageObject, setImageObject] = React.useState<
@ -74,6 +78,11 @@ export default function ImageUpload({
>(externalImage); >(externalImage);
const [src, setSrc] = React.useState<string | undefined>(existingImageUrl); const [src, setSrc] = React.useState<string | undefined>(existingImageUrl);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const imageUrlRef = React.useRef("");
React.useEffect(() => {
setImgURL?.(src);
}, [src]);
React.useEffect(() => { React.useEffect(() => {
if (existingImageUrl) setSrc(existingImageUrl); if (existingImageUrl) setSrc(existingImageUrl);
@ -84,7 +93,7 @@ export default function ImageUpload({
{...props} {...props}
className={twMerge( className={twMerge(
"w-full h-[300px] overflow-hidden", "w-full h-[300px] overflow-hidden",
props?.className props?.className,
)} )}
> >
<input <input
@ -123,7 +132,7 @@ export default function ImageUpload({
externalSetImage?.(res); externalSetImage?.(res);
fileInputProps?.onChange?.(e); fileInputProps?.onChange?.(e);
setLoading?.(false); setLoading?.(false);
} },
); );
} }
}} }}
@ -138,7 +147,7 @@ export default function ImageUpload({
{label && ( {label && (
<label <label
className={twMerge( className={twMerge(
"absolute top-0 left-0 text-xs z-50" "absolute top-0 left-0 text-xs z-50",
)} )}
> >
<Tag color="gray"> <Tag color="gray">
@ -157,10 +166,10 @@ export default function ImageUpload({
{...previewImageProps} {...previewImageProps}
/> />
)} )}
<Button <div
variant="ghost"
className={twMerge( className={twMerge(
"absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark" "absolute p-1 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
"cursor-pointer",
)} )}
onClick={(e) => { onClick={(e) => {
setSrc(undefined); setSrc(undefined);
@ -174,13 +183,13 @@ export default function ImageUpload({
title="Cancel Image Upload Button" title="Cancel Image Upload Button"
> >
<X className="text-slate-950 dark:text-white" /> <X className="text-slate-950 dark:text-white" />
</Button> </div>
</Card> </Card>
) : ( ) : (
<Card <Card
className={twMerge( className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20", "w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className placeHolderWrapper?.className,
)} )}
onClick={(e) => { onClick={(e) => {
const targetEl = e.target as HTMLElement | undefined; const targetEl = e.target as HTMLElement | undefined;
@ -199,6 +208,30 @@ export default function ImageUpload({
<Span size="smaller" variant="faded"> <Span size="smaller" variant="faded">
{label || "Click to Upload Image"} {label || "Click to Upload Image"}
</Span> </Span>
<Stack className="cancel-upload w-full items-stretch gap-1">
<Input
placeholder="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;
}}
showLabel
/>
<Button
title="Restore Image Button"
size="smaller"
variant="outlined"
color="gray"
onClick={() => {
if (!imageUrlRef.current) return;
setSrc(imageUrlRef.current);
}}
>
Set Image URL
</Button>
</Stack>
{existingImageUrl && ( {existingImageUrl && (
<Button <Button
title="Restore Image Button" title="Restore Image Button"

View File

@ -8,7 +8,7 @@ let pressInterval: any;
let pressTimeout: any; let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step"> & { type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
updateValue: (v: string) => void; setValue: React.Dispatch<React.SetStateAction<string>>;
getNormalizedValue: (v: string) => void; getNormalizedValue: (v: string) => void;
buttonDownRef: React.MutableRefObject<boolean>; buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>; inputRef: React.RefObject<HTMLInputElement | null>;
@ -19,7 +19,7 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
*/ */
export default function NumberInputButtons({ export default function NumberInputButtons({
getNormalizedValue, getNormalizedValue,
updateValue, setValue,
min, min,
max, max,
step, step,
@ -65,12 +65,14 @@ export default function NumberInputButtons({
const existingNumberValue = twuiNumberfy(existingValue); const existingNumberValue = twuiNumberfy(existingValue);
if (max && existingNumberValue >= twuiNumberfy(max)) { if (max && existingNumberValue >= twuiNumberfy(max)) {
return updateValue(String(max)); return setValue(String(max));
} else if (min && existingNumberValue < twuiNumberfy(min)) { } else if (min && existingNumberValue < twuiNumberfy(min)) {
return updateValue(String(min)); return setValue(String(min));
} else { } else {
updateValue( setValue(
String(existingNumberValue + twuiNumberfy(step || DEFAULT_STEP)) String(
existingNumberValue + twuiNumberfy(step || DEFAULT_STEP),
),
); );
} }
} }
@ -80,10 +82,12 @@ export default function NumberInputButtons({
const existingNumberValue = twuiNumberfy(existingValue); const existingNumberValue = twuiNumberfy(existingValue);
if (min && existingNumberValue <= twuiNumberfy(min)) { if (min && existingNumberValue <= twuiNumberfy(min)) {
updateValue(String(min)); setValue(String(min));
} else { } else {
updateValue( setValue(
String(existingNumberValue - twuiNumberfy(step || DEFAULT_STEP)) String(
existingNumberValue - twuiNumberfy(step || DEFAULT_STEP),
),
); );
} }
} }

View File

@ -27,13 +27,16 @@ let timeout: any;
let validationFnTimeout: any; let validationFnTimeout: any;
let externalValueChangeTimeout: any; let externalValueChangeTimeout: any;
export type InputProps<KeyType extends string> = DetailedHTMLProps< export type InputProps<KeyType extends string> = Omit<
InputHTMLAttributes<HTMLInputElement>, DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
HTMLInputElement "prefix" | "suffix"
> & > &
Omit<
DetailedHTMLProps< DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>, TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement HTMLTextAreaElement
>,
"prefix" | "suffix"
> & { > & {
label?: string; label?: string;
variant?: "normal" | "warning" | "error" | "inactive"; variant?: "normal" | "warning" | "error" | "inactive";
@ -60,16 +63,14 @@ export type InputProps<KeyType extends string> = DetailedHTMLProps<
invalidMessage?: string; invalidMessage?: string;
validationFunction?: ( validationFunction?: (
value: string, value: string,
element?: HTMLInputElement | HTMLTextAreaElement element?: HTMLInputElement | HTMLTextAreaElement,
) => Promise<TWUISelectValidityObject>; ) => Promise<TWUISelectValidityObject>;
changeHandler?: ( changeHandler?: (value: string) => void;
value: string,
element?: HTMLInputElement | HTMLTextAreaElement
) => void;
autoComplete?: (typeof AutocompleteOptions)[number]; autoComplete?: (typeof AutocompleteOptions)[number];
name?: KeyType; name?: KeyType;
valueUpdate?: string; valueUpdate?: string;
numberText?: boolean; numberText?: boolean;
rawNumber?: boolean;
setReady?: React.Dispatch<React.SetStateAction<boolean>>; setReady?: React.Dispatch<React.SetStateAction<boolean>>;
decimal?: number; decimal?: number;
info?: string | ReactNode; info?: string | ReactNode;
@ -91,7 +92,7 @@ let refreshes = 0;
* @className twui-clear-input-field-button * @className twui-clear-input-field-button
*/ */
export default function Input<KeyType extends string>( export default function Input<KeyType extends string>(
inputProps: InputProps<KeyType> inputProps: InputProps<KeyType>,
) { ) {
const { const {
label, label,
@ -119,10 +120,12 @@ export default function Input<KeyType extends string>(
changeHandler, changeHandler,
validity: existingValidity, validity: existingValidity,
clearInputProps, clearInputProps,
rawNumber,
...props ...props
} = inputProps; } = inputProps;
function getFinalValue(v: any) { function getFinalValue(v: any) {
if (rawNumber) return twuiNumberfy(v);
if (numberText) { if (numberText) {
return ( return (
twuiNumberfy(v, decimal).toLocaleString() + twuiNumberfy(v, decimal).toLocaleString() +
@ -141,16 +144,19 @@ export default function Input<KeyType extends string>(
const [validity, setValidity] = React.useState<TWUISelectValidityObject>( const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || { existingValidity || {
isValid: true, isValid: true,
} },
); );
const inputRef = componentRef || React.useRef<HTMLInputElement>(null); const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null); const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const buttonDownRef = React.useRef(false); const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
props.defaultValue ? String(props.defaultValue) : "",
);
const [focus, setFocus] = React.useState(false); const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState( const [inputType, setInputType] = React.useState(
numberText ? "text" : props.type numberText ? "text" : props.type,
); );
const DEFAULT_DEBOUNCE = 500; const DEFAULT_DEBOUNCE = 500;
@ -180,23 +186,20 @@ export default function Input<KeyType extends string>(
setValidity(existingValidity); setValidity(existingValidity);
}, [existingValidity]); }, [existingValidity]);
const updateValueFn = ( const updateValueFn = (val: string) => {
val: string,
el?: HTMLInputElement | HTMLTextAreaElement
) => {
if (buttonDownRef.current) return; if (buttonDownRef.current) return;
if (changeHandler) { if (changeHandler) {
window.clearTimeout(externalValueChangeTimeout); window.clearTimeout(externalValueChangeTimeout);
externalValueChangeTimeout = setTimeout(() => { externalValueChangeTimeout = setTimeout(() => {
changeHandler(val, el); changeHandler(val);
}, finalDebounce); }, finalDebounce);
} }
if (typeof val == "string") { if (typeof val == "string") {
if (!val.match(/./)) { if (!val.match(/./)) {
setValidity({ isValid: true }); setValidity({ isValid: true });
props.value = ""; setValue("");
if (istextarea && textAreaRef.current) { if (istextarea && textAreaRef.current) {
textAreaRef.current.value = ""; textAreaRef.current.value = "";
} else if (inputRef?.current) { } else if (inputRef?.current) {
@ -223,7 +226,7 @@ export default function Input<KeyType extends string>(
if (validationRegex && !validationRegex.test(val)) { if (validationRegex && !validationRegex.test(val)) {
return; return;
} }
validationFunction(val, el).then((res) => { validationFunction(val).then((res) => {
setValidity(res); setValidity(res);
}); });
}, finalDebounce); }, finalDebounce);
@ -233,28 +236,36 @@ export default function Input<KeyType extends string>(
React.useEffect(() => { React.useEffect(() => {
if (typeof props.value !== "string" || !props.value.match(/./)) return; if (typeof props.value !== "string" || !props.value.match(/./)) return;
updateValueFn(String(props.value)); setValue(String(props.value));
}, [props.value]); }, [props.value]);
React.useEffect(() => {
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(value);
}
updateValueFn(value);
}, [value]);
function handleValueChange( function handleValueChange(
e: React.ChangeEvent<HTMLInputElement> & e: React.ChangeEvent<HTMLInputElement> &
React.ChangeEvent<HTMLTextAreaElement> React.ChangeEvent<HTMLTextAreaElement>,
) { ) {
const newValue = e.target.value; const newValue = e.target.value;
updateValue(newValue, e.target); setValue(newValue);
props.onChange?.(e); props.onChange?.(e);
} }
function updateValue( // function updateValue(
v: string, // v: string,
el?: HTMLInputElement | HTMLTextAreaElement // el?: HTMLInputElement | HTMLTextAreaElement,
) { // ) {
if (istextarea && textAreaRef.current) { // if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) { // } else if (inputRef?.current) {
inputRef.current.value = getFinalValue(v); // inputRef.current.value = getFinalValue(v);
} // }
updateValueFn(v, el); // updateValueFn(v);
} // }
const targetComponent = istextarea ? ( const targetComponent = istextarea ? (
<textarea <textarea
@ -265,7 +276,7 @@ export default function Input<KeyType extends string>(
className={twMerge( className={twMerge(
"w-full outline-none bg-transparent grow", "w-full outline-none bg-transparent grow",
"twui-textarea", "twui-textarea",
props.className props.className,
)} )}
ref={textAreaRef} ref={textAreaRef}
onFocus={(e) => { onFocus={(e) => {
@ -294,7 +305,7 @@ export default function Input<KeyType extends string>(
"dark:bg-transparent dark:outline-none dark:border-none", "dark:bg-transparent dark:outline-none dark:border-none",
"p-0 grow", "p-0 grow",
"twui-input", "twui-input",
props.className props.className,
)} )}
ref={inputRef} ref={inputRef}
onFocus={(e) => { onFocus={(e) => {
@ -318,8 +329,8 @@ export default function Input<KeyType extends string>(
title={`${finalLabel}${props.required ? " (Required)" : ""}`} title={`${finalLabel}${props.required ? " (Required)" : ""}`}
{...wrapperWrapperProps} {...wrapperWrapperProps}
className={twMerge( className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-10", "w-full gap-1.5 relative z-0 hover:z-100",
wrapperWrapperProps?.className wrapperWrapperProps?.className,
)} )}
> >
<div <div
@ -353,7 +364,7 @@ export default function Input<KeyType extends string>(
: "opacity-50 pointer-events-none" : "opacity-50 pointer-events-none"
: undefined, : undefined,
"twui-input-wrapper", "twui-input-wrapper",
wrapperProps?.className wrapperProps?.className,
)} )}
> >
{showLabel && ( {showLabel && (
@ -365,7 +376,7 @@ export default function Input<KeyType extends string>(
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap", "dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
"overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default", "overflow-hidden overflow-ellipsis z-20 px-1.5 rounded-t-default",
"twui-input-label", "twui-input-label",
labelProps?.className labelProps?.className,
)} )}
> >
{finalLabel} {finalLabel}
@ -378,11 +389,7 @@ export default function Input<KeyType extends string>(
</label> </label>
)} )}
{prefix && ( {prefix && prefix}
<div className="opacity-60 pointer-events-none whitespace-nowrap">
{prefix}
</div>
)}
{targetComponent} {targetComponent}
@ -394,7 +401,7 @@ export default function Input<KeyType extends string>(
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer w-7 h-7", "p-1 -my-2 -mx-1 opacity-0 cursor-pointer w-7 h-7",
"bg-background-light dark:bg-background-dark", "bg-background-light dark:bg-background-dark",
"twui-clear-input-field-button", "twui-clear-input-field-button",
clearInputProps?.className clearInputProps?.className,
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -406,7 +413,7 @@ export default function Input<KeyType extends string>(
textAreaRef.current.value = ""; textAreaRef.current.value = "";
} }
updateValue(""); setValue("");
clearInputProps?.onClick?.(e); clearInputProps?.onClick?.(e);
}} }}
> >
@ -439,21 +446,11 @@ export default function Input<KeyType extends string>(
</div> </div>
) : null} ) : null}
{suffix ? ( {suffix ? suffix : null}
<div
{...suffixProps}
className={twMerge(
"opacity-60 pointer-events-none whitespace-nowrap",
suffixProps?.className
)}
>
{suffix}
</div>
) : null}
{numberText ? ( {numberText ? (
<NumberInputButtons <NumberInputButtons
updateValue={updateValue} setValue={setValue}
inputRef={inputRef} inputRef={inputRef}
getNormalizedValue={getNormalizedValue} getNormalizedValue={getNormalizedValue}
max={props.max} max={props.max}
@ -468,18 +465,22 @@ export default function Input<KeyType extends string>(
target={ target={
<Row className="gap-1"> <Row className="gap-1">
<Info size={12} className="opacity-40" /> <Info size={12} className="opacity-40" />
<Span size="smaller" className="opacity-70"> <Span
size="smaller"
className="opacity-70 hover:opacity-100"
>
{info} {info}
</Span> </Span>
</Row> </Row>
} }
openDebounce={700} openDebounce={700}
className="z-1000"
hoverOpen hoverOpen
> >
<Paper <Paper
className={twMerge( className={twMerge(
"min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10", "min-w-[250px] shadow-lg shadow-slate-200 dark:shadow-white/10",
"max-w-[300px] w-full" "max-w-[300px] w-full bg-background-light! dark:bg-background-dark! z-1000",
)} )}
> >
<Stack className="gap-2 items-center"> <Stack className="gap-2 items-center">

View File

@ -6,7 +6,7 @@ type Params = {
let timeout: any; let timeout: any;
export default function twuiUseReady(params?: Params) { export default function useReady(params?: Params) {
const [ready, setReady] = React.useState(false); const [ready, setReady] = React.useState(false);
const finalTimeout = params?.timeout || 300; const finalTimeout = params?.timeout || 300;

View File

@ -0,0 +1,31 @@
import React from "react";
type Params = {
initialLoading?: boolean;
initialReady?: boolean;
};
export type UseStatusStatusType = {
msg?: string;
error?: 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<UseStatusStatusType>({});
const [ready, setReady] = React.useState(params?.initialReady || false);
return {
refresh,
setRefresh,
loading,
setLoading,
status,
setStatus,
ready,
setReady,
};
}

View File

@ -1,17 +1,16 @@
import React from "react"; import React, { useRef } from "react";
export type UseWebsocketHookParams = { export type UseWebsocketHookParams = {
debounce?: number; debounce?: number;
url: string; url: string;
disableReconnect?: boolean; disableReconnect?: boolean;
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
keepAliveDuration?: number; keepAliveDuration?: number;
refreshConnection?: number; refreshConnection?: number;
}; };
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const; export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
let tries = 0;
/** /**
* # Use Websocket Hook * # Use Websocket Hook
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events * @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
@ -33,18 +32,20 @@ export default function useWebSocket<
keepAliveDuration, keepAliveDuration,
refreshConnection, refreshConnection,
}: UseWebsocketHookParams) { }: UseWebsocketHookParams) {
const DEBOUNCE = debounce || 200; const DEBOUNCE = debounce || 500;
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30; const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3; const KEEP_ALIVE_TIMEOUT = 1000 * 60 * 3;
const KEEP_ALIVE_MESSAGE = "twui::ping"; const KEEP_ALIVE_MESSAGE = "twui::ping";
let uptime = 0; let uptime = 0;
let tries = useRef(0);
let reconnectInterval: any; // const queue: string[] = [];
let msgInterval: any;
let sendInterval: any; const msgInterval = useRef<any>(null);
let keepAliveInterval: any; const sendInterval = useRef<any>(null);
const keepAliveInterval = useRef<any>(null);
const [socket, setSocket] = React.useState<WebSocket | undefined>( const [socket, setSocket] = React.useState<WebSocket | undefined>(
undefined undefined
@ -77,155 +78,110 @@ export default function useWebSocket<
const wsURL = url.startsWith(`ws`) const wsURL = url.startsWith(`ws`)
? url ? url
: domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/"); : domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/");
if (!wsURL) return; if (!wsURL) return;
let ws = new WebSocket(wsURL); let ws = new WebSocket(wsURL);
ws.onopen = (ev) => { ws.onerror = (ev) => {
window.clearInterval(reconnectInterval); console.log(`Websocket ERROR:`);
window.clearInterval(keepAliveInterval);
keepAliveInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(KEEP_ALIVE_MESSAGE);
uptime += KEEP_ALIVE_DURATION;
if (uptime >= KEEP_ALIVE_TIMEOUT) {
console.log("Websocket connection timed out ...");
window.clearInterval(keepAliveInterval);
ws.close();
}
}
}, KEEP_ALIVE_DURATION);
setSocket(ws);
tries = 0;
console.log(`Websocket connected to ${wsURL}`);
uptime = 0;
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
window.clearInterval(msgInterval);
messageQueueRef.current.push(ev.data); messageQueueRef.current.push(ev.data);
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE); };
if (ev.data !== KEEP_ALIVE_MESSAGE) {
uptime = 0; ws.onopen = (ev) => {
window.clearInterval(keepAliveInterval.current);
keepAliveInterval.current = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(KEEP_ALIVE_MESSAGE);
} }
}, KEEP_ALIVE_DURATION);
setSocket(ws);
console.log(`Websocket connected to ${wsURL}`);
}; };
ws.onclose = (ev) => { ws.onclose = (ev) => {
console.log("Websocket closed!"); console.log("Websocket closed!", {
code: ev.code,
reason: ev.reason,
wasClean: ev.wasClean,
});
if (disableReconnect) return; if (disableReconnect) return;
console.log("Attempting to reconnect ..."); console.log("Attempting to reconnect ...");
console.log("URL:", url); console.log("URL:", url);
window.clearInterval(keepAliveInterval); window.clearInterval(keepAliveInterval.current);
reconnectInterval = setInterval(() => { console.log("tries", tries);
if (tries >= 3) {
return window.clearInterval(reconnectInterval); if (tries.current >= 3) {
return;
} }
console.log("Attempting to reconnect ..."); console.log("Attempting to reconnect ...");
tries++;
tries.current += 1;
connect(); connect();
}, 1000);
}; };
}, []); }, []);
/**
* # Window Close Handler
*/
const handleWindowClose = React.useCallback(() => {
console.log("Window Unloaded ...");
}, [socket]);
/**
* # Window Focus Handler
*/
const handleWindowFocus = React.useCallback(() => {
if (socket?.readyState === WebSocket.CLOSED) {
console.log("Websocket closed ... Attempting to reconnect ...");
connect();
}
if (socket?.readyState === WebSocket.OPEN) {
console.log("Websocket connection alive ...");
socket.send(KEEP_ALIVE_MESSAGE);
uptime = 0;
}
}, [socket]);
/** /**
* # Initial Connection * # Initial Connection
*/ */
React.useEffect(() => { React.useEffect(() => {
connect(); if (socket) return;
return function () { connect();
window.clearInterval(reconnectInterval);
};
}, []); }, []);
/**
* # Window Close and Focus Handlers
*/
React.useEffect(() => { React.useEffect(() => {
if (!socket) return; if (!socket) return;
window.addEventListener("beforeunload", handleWindowClose, { sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
once: true, msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
});
window.addEventListener("focus", handleWindowFocus);
return function () { return function () {
window.removeEventListener("focus", handleWindowFocus); window.clearInterval(sendInterval.current);
window.removeEventListener("beforeunload", handleWindowClose); window.clearInterval(msgInterval.current);
}; };
}, [socket]); }, [socket]);
/**
* # Refresh Connection
*/
React.useEffect(() => {
console.log("Refreshing connection ...");
if (!socket) return;
if (socket.readyState !== WebSocket.CLOSED) {
socket?.close();
}
connect();
}, [refreshConnection]);
/** /**
* Received Message Queue Handler * Received Message Queue Handler
*/ */
const handleReceivedMessageQueue = React.useCallback(() => { const handleReceivedMessageQueue = React.useCallback(() => {
if (messageQueueRef.current.length > 0) {
const newMessage = messageQueueRef.current.shift();
if (!newMessage) return;
try { try {
const jsonData = JSON.parse(newMessage); const msg = messageQueueRef.current.shift();
dispatchCustomEvent("wsMessageEvent", newMessage);
if (!msg) return;
const jsonData = JSON.parse(msg);
dispatchCustomEvent("wsMessageEvent", msg);
dispatchCustomEvent("wsDataEvent", jsonData); dispatchCustomEvent("wsDataEvent", jsonData);
} catch (error) { } catch (error) {
console.log("Unable to parse string. Returning string."); console.log("Unable to parse string. Returning string.");
} }
} else {
window.clearInterval(msgInterval);
uptime = 0;
}
}, []); }, []);
/** /**
* Send Message Queue Handler * Send Message Queue Handler
*/ */
const handleSendMessageQueue = React.useCallback(() => { const handleSendMessageQueue = React.useCallback(() => {
if (sendMessageQueueRef.current.length > 0) { if (!socket || socket.readyState !== WebSocket.OPEN) {
window.clearInterval(sendInterval.current);
return;
}
const newMessage = sendMessageQueueRef.current.shift(); const newMessage = sendMessageQueueRef.current.shift();
if (!newMessage) return; if (!newMessage) return;
socket?.send(newMessage);
} else { socket.send(newMessage);
window.clearInterval(sendInterval);
}
}, [socket]); }, [socket]);
/** /**
@ -234,9 +190,14 @@ export default function useWebSocket<
const sendData = React.useCallback( const sendData = React.useCallback(
(data: T) => { (data: T) => {
try { try {
window.clearInterval(sendInterval); const queueItemJSON = JSON.stringify(data);
sendMessageQueueRef.current.push(JSON.stringify(data));
sendInterval = setInterval(handleSendMessageQueue, DEBOUNCE); const existingQueue = sendMessageQueueRef.current.find(
(q) => q == queueItemJSON
);
if (!existingQueue) {
sendMessageQueueRef.current.push(queueItemJSON);
}
} catch (error: any) { } catch (error: any) {
console.log("Error Sending socket message", error.message); console.log("Error Sending socket message", error.message);
} }

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

View File

@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Loading from "../elements/Loading"; import Loading from "../elements/Loading";
import LucideIcon, { TWUILucideIconName } from "../elements/lucide-icon";
export type TWUIButtonProps = DetailedHTMLProps< export type TWUIButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>, ButtonHTMLAttributes<HTMLButtonElement>,
@ -34,8 +35,8 @@ export type TWUIButtonProps = DetailedHTMLProps<
AnchorHTMLAttributes<HTMLAnchorElement>, AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement HTMLAnchorElement
>; >;
beforeIcon?: React.ReactNode; beforeIcon?: TWUILucideIconName | JSX.Element;
afterIcon?: React.ReactNode; afterIcon?: TWUILucideIconName | JSX.Element;
buttonContentProps?: DetailedHTMLProps< buttonContentProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
@ -117,132 +118,132 @@ export default function Button({
return twMerge( return twMerge(
"bg-primary hover:bg-primary-hover text-white", "bg-primary hover:bg-primary-hover text-white",
"dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white", "dark:bg-primary-dark hover:dark:bg-primary-dark-hover text-white",
"twui-button-primary" "twui-button-primary",
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-secondary hover:bg-secondary-hover text-white", "bg-secondary hover:bg-secondary-hover text-white",
"twui-button-secondary" "twui-button-secondary",
); );
if (color == "white") if (color == "white")
return twMerge( return twMerge(
"!bg-white hover:!bg-slate-200 !text-slate-800", "!bg-white hover:!bg-slate-200 !text-slate-800",
"twui-button-white" "twui-button-white",
); );
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-accent hover:bg-accent-hover text-white", "bg-accent hover:bg-accent-hover text-white",
"twui-button-accent" "twui-button-accent",
); );
if (color == "gray") if (color == "gray")
return twMerge( return twMerge(
"bg-gray hover:bg-gray-hover text-foreground-light", "bg-gray hover:bg-gray-hover text-foreground-light",
"dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark", "dark:bg-gray-dark hover:dark:bg-gray-dark-hover dark:text-foreground-dark",
"twui-button-gray" "twui-button-gray",
); );
if (color == "success") if (color == "success")
return twMerge( return twMerge(
"bg-success hover:bg-success-hover text-white", "bg-success hover:bg-success-hover text-white",
"dark:bg-success hover:dark:bg-success-hover text-white", "dark:bg-success hover:dark:bg-success-hover text-white",
"twui-button-success" "twui-button-success",
); );
if (color == "error") if (color == "error")
return twMerge( return twMerge(
"bg-error hover:bg-error-hover text-white", "bg-error hover:bg-error-hover text-white",
"dark:bg-error hover:dark:bg-error-hover text-white", "dark:bg-error hover:dark:bg-error-hover text-white",
"twui-button-error" "twui-button-error",
); );
} else if (variant == "outlined") { } else if (variant == "outlined") {
if (color == "primary" || !color) if (color == "primary" || !color)
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-primary", "bg-transparent outline outline-1 outline-primary",
"text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline", "text-primary-text dark:text-primary-dark-text dark:outline-primary-dark-outline",
"twui-button-primary-outlined" "twui-button-primary-outlined",
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-secondary", "bg-transparent outline outline-1 outline-secondary",
"text-secondary", "text-secondary",
"twui-button-secondary-outlined" "twui-button-secondary-outlined",
); );
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-accent", "bg-transparent outline outline-1 outline-accent",
"text-accent", "text-accent",
"twui-button-accent-outlined" "twui-button-accent-outlined",
); );
if (color == "gray") if (color == "gray")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-slate-300", "bg-transparent outline outline-1 outline-slate-300",
"text-slate-600 dark:text-white/60 dark:outline-white/30", "text-slate-600 dark:text-white/60 dark:outline-white/30",
"twui-button-gray-outlined" "twui-button-gray-outlined",
); );
if (color == "white") if (color == "white")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-white/50", "bg-transparent outline outline-1 outline-white/50",
"text-white", "text-white",
"twui-button-white-outlined" "twui-button-white-outlined",
); );
if (color == "error") if (color == "error")
return twMerge( return twMerge(
"bg-transparent outline outline-1 outline-error text-error", "bg-transparent outline outline-1 outline-error text-error",
"dark:outline-error dark:text-error-dark", "dark:outline-error dark:text-error-dark",
"twui-button-error-outlined" "twui-button-error-outlined",
); );
} else if (variant == "ghost") { } else if (variant == "ghost") {
if (color == "primary" || !color) if (color == "primary" || !color)
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent", "text-primary-text dark:text-primary-dark-text hover:bg-transparent dark:hover:bg-transparent",
"twui-button-primary-ghost" "twui-button-primary-ghost",
); );
if (color == "secondary") if (color == "secondary")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-secondary hover:bg-transparent dark:hover:bg-transparent", "text-secondary hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost" "twui-button-secondary-ghost",
); );
if (color == "text") if (color == "text")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 dark:text-foreground-dark", "bg-transparent dark:bg-transparent outline-none p-2 dark:text-foreground-dark",
"text-foreground-light hover:bg-transparent dark:hover:bg-transparent", "text-foreground-light hover:bg-transparent dark:hover:bg-transparent",
"twui-button-secondary-ghost" "twui-button-secondary-ghost",
); );
if (color == "accent") if (color == "accent")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2", "bg-transparent dark:bg-transparent outline-none p-2",
"text-accent hover:bg-transparent dark:hover:bg-transparent", "text-accent hover:bg-transparent dark:hover:bg-transparent",
"twui-button-accent-ghost" "twui-button-accent-ghost",
); );
if (color == "gray") if (color == "gray")
return twMerge( return twMerge(
"bg-transparent dark:bg-transparent outline-none p-2 hover:bg-transparent dark:hover:bg-transparent", "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", "text-slate-600 dark:text-white/70 hover:opacity-80",
"twui-button-gray-ghost" "twui-button-gray-ghost",
); );
if (color == "error") if (color == "error")
return twMerge( return twMerge(
"bg-transparent outline-none p-2", "bg-transparent outline-none p-2",
"text-red-600 dark:text-red-400", "text-red-600 dark:text-red-400",
"twui-button-error-ghost" "twui-button-error-ghost",
); );
if (color == "warning") if (color == "warning")
return twMerge( return twMerge(
"bg-transparent outline-none p-2", "bg-transparent outline-none p-2",
"text-yellow-600", "text-yellow-600",
"twui-button-warning-ghost" "twui-button-warning-ghost",
); );
if (color == "success") if (color == "success")
return twMerge( return twMerge(
"bg-transparent outline-none p-2", "bg-transparent outline-none p-2",
"text-success", "text-success",
"twui-button-success-ghost" "twui-button-success-ghost",
); );
if (color == "white") if (color == "white")
return twMerge( return twMerge(
"bg-transparent outline-none p-2", "bg-transparent outline-none p-2",
"text-white", "text-white",
"twui-button-white-ghost" "twui-button-white-ghost",
); );
} }
@ -268,7 +269,7 @@ export default function Button({
: "twui-button-base", : "twui-button-base",
finalClassName, finalClassName,
loading ? "pointer-events-none opacity-80" : "", loading ? "pointer-events-none opacity-80" : "",
props.className props.className,
)} )}
aria-label={props.title} aria-label={props.title}
> >
@ -278,12 +279,24 @@ export default function Button({
"flex flex-row items-center gap-2 whitespace-nowrap", "flex flex-row items-center gap-2 whitespace-nowrap",
loading ? "opacity-0" : "", loading ? "opacity-0" : "",
"twui-button-content-wrapper", "twui-button-content-wrapper",
buttonContentProps?.className buttonContentProps?.className,
)} )}
> >
{beforeIcon && beforeIcon} {beforeIcon ? (
typeof beforeIcon == "string" ? (
<LucideIcon name={beforeIcon as TWUILucideIconName} />
) : (
beforeIcon
)
) : null}
{props.children} {props.children}
{afterIcon && afterIcon} {afterIcon ? (
typeof afterIcon == "string" ? (
<LucideIcon name={afterIcon as TWUILucideIconName} />
) : (
afterIcon
)
) : null}
</div> </div>
{loading && ( {loading && (

View File

@ -9,6 +9,7 @@ export type LoadingRectangleBlockProps = DetailedHTMLProps<
/** /**
* # A loading Rectangle block * # A loading Rectangle block
* @className twui-loading-rectangle-block * @className twui-loading-rectangle-block
* @className twui-loading-block
*/ */
export default function LoadingRectangleBlock({ export default function LoadingRectangleBlock({
...props ...props
@ -19,8 +20,8 @@ export default function LoadingRectangleBlock({
className={twMerge( className={twMerge(
"flex items-center w-full h-10 animate-pulse bg-slate-200 rounded", "flex items-center w-full h-10 animate-pulse bg-slate-200 rounded",
"dark:bg-slate-800", "dark:bg-slate-800",
"twui-loading-rectangle-block", "twui-loading-rectangle-block twui-loading-block",
props.className props.className,
)} )}
> >
{props.children} {props.children}

View File

@ -8,10 +8,12 @@ import { twMerge } from "tailwind-merge";
export default function Span({ export default function Span({
size, size,
variant, variant,
truncate,
...props ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement> & { }: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement> & {
size?: "normal" | "small" | "smaller" | "large" | "larger"; size?: "normal" | "small" | "smaller" | "large" | "larger";
variant?: "normal" | "faded"; variant?: "normal" | "faded";
truncate?: { lines?: number; width?: number };
}) { }) {
return ( return (
<span <span
@ -23,8 +25,9 @@ export default function Span({
size == "large" && "text-lg", size == "large" && "text-lg",
size == "larger" && "text-xl", size == "larger" && "text-xl",
variant == "faded" && "opacity-50", variant == "faded" && "opacity-50",
truncate ? `` : ``,
"twui-span", "twui-span",
props.className props.className,
)} )}
> >
{props.children} {props.children}

View File

@ -48,9 +48,7 @@ export default function MarkdownEditorPreviewComponent({
.then((mdxSrc) => { .then((mdxSrc) => {
setMdxSource(mdxSrc); setMdxSource(mdxSrc);
}) })
.catch((err) => { .catch((err) => {});
console.log(`Markdown Parsing Error => ${err.message}`);
});
} catch (error) {} } catch (error) {}
}, [value]); }, [value]);

View File

@ -0,0 +1,38 @@
/**
* # EJSON parse string
*/
function parse(
string: string | null | number,
reviver?: (this: any, key: string, value: any) => any,
): { [s: string]: any } | { [s: string]: any }[] | undefined {
if (!string) return undefined;
if (typeof string == "object") return string;
if (typeof string !== "string") return undefined;
try {
return JSON.parse(string, reviver);
} catch (error) {
return undefined;
}
}
/**
* # EJSON stringify object
*/
function stringify(
value: any,
replacer?: ((this: any, key: string, value: any) => any) | null,
space?: string | number,
): string | undefined {
try {
return JSON.stringify(value, replacer || undefined, space);
} catch (error) {
return undefined;
}
}
const TWUIEJSON = {
parse,
stringify,
};
export default TWUIEJSON;

View File

@ -1,4 +1,5 @@
import _ from "lodash"; import _ from "lodash";
import twuiSerializeQuery from "../serialize-query";
export const FetchAPIMethods = [ export const FetchAPIMethods = [
"POST", "POST",
@ -17,6 +18,10 @@ type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
method: (typeof FetchAPIMethods)[number]; method: (typeof FetchAPIMethods)[number];
body?: T | string; body?: T | string;
headers?: FetchHeader; headers?: FetchHeader;
query?: T;
csrfValue?: string;
csrfKey?: string;
fetchOptions?: RequestInit;
}; };
type FetchHeader = HeadersInit & { type FetchHeader = HeadersInit & {
@ -35,32 +40,22 @@ export type FetchApiReturn = {
*/ */
export default async function fetchApi< export default async function fetchApi<
T extends { [k: string]: any } = { [k: string]: any }, T extends { [k: string]: any } = { [k: string]: any },
R extends any = any R extends any = any,
>( >(url: string, options?: FetchApiOptions<T>): Promise<R> {
url: string,
options?: FetchApiOptions<T>,
csrf?: boolean,
/**
* Key to use to grab local Storage csrf value.
*/
localStorageCSRFKey?: string,
/**
* Key with which to set the request header csrf
* value
*/
csrfHeaderKey?: string
): Promise<R> {
let data; let data;
const csrfKey = "x-dsql-csrf-key";
const csrfValue = localStorage.getItem(localStorageCSRFKey || csrfKey);
let finalHeaders = { let finalHeaders = {
"Content-Type": "application/json", "Content-Type": "application/json",
} as FetchHeader; } as FetchHeader;
if (csrf && csrfValue) { if (options?.csrfKey && options.csrfValue) {
finalHeaders[localStorageCSRFKey || csrfKey] = csrfValue; finalHeaders[options.csrfKey] = options.csrfValue;
}
let finalURL = url;
if (options?.query) {
finalURL += twuiSerializeQuery(options.query);
} }
if (typeof options === "string") { if (typeof options === "string") {
@ -69,7 +64,7 @@ export default async function fetchApi<
switch (options) { switch (options) {
case "post": case "post":
fetchData = await fetch(url, { fetchData = await fetch(finalURL, {
method: options, method: options,
headers: finalHeaders, headers: finalHeaders,
} as RequestInit); } as RequestInit);
@ -77,7 +72,7 @@ export default async function fetchApi<
break; break;
default: default:
fetchData = await fetch(url); fetchData = await fetch(finalURL);
data = fetchData.json(); data = fetchData.json();
break; break;
} }
@ -98,14 +93,14 @@ export default async function fetchApi<
options.headers = _.merge(options.headers, finalHeaders); options.headers = _.merge(options.headers, finalHeaders);
const finalOptions: any = { ...options }; const finalOptions: any = { ...options };
fetchData = await fetch(url, finalOptions); fetchData = await fetch(finalURL, finalOptions);
} else { } else {
const finalOptions = { const finalOptions = {
...options, ...options,
headers: finalHeaders, headers: finalHeaders,
} as RequestInit; } as RequestInit;
fetchData = await fetch(url, finalOptions); fetchData = await fetch(finalURL, finalOptions);
} }
data = fetchData.json(); data = fetchData.json();
@ -115,7 +110,7 @@ export default async function fetchApi<
} }
} else { } else {
try { try {
let fetchData = await fetch(url); let fetchData = await fetch(finalURL);
data = await fetchData.json(); data = await fetchData.json();
} catch (error: any) { } catch (error: any) {
console.log("FetchAPI error #3:", error.message); console.log("FetchAPI error #3:", error.message);

View File

@ -25,7 +25,6 @@ export default function twuiNumberfy(num: any, decimals?: number): number {
return Number(numberfiedNum.toFixed(existingDecimals)); return Number(numberfiedNum.toFixed(existingDecimals));
return Math.round(numberfiedNum); return Math.round(numberfiedNum);
} catch (error: any) { } catch (error: any) {
console.log(`Numberfy ERROR: ${error.message}`);
return 0; return 0;
} }
} }

View File

@ -0,0 +1,42 @@
import TWUIEJSON from "./ejson";
/**
* # Serialize Query
*/
export default function twuiSerializeQuery(query: any): string {
let str = "?";
if (typeof query !== "object") {
console.log("Invalid Query type");
return str;
}
if (Array.isArray(query)) {
console.log("Query is an Array. This is invalid.");
return str;
}
if (!query) {
console.log("No Query provided.");
return str;
}
const keys = Object.keys(query);
const queryArr: string[] = [];
keys.forEach((key) => {
if (!key || !query[key]) return;
const value = query[key];
if (typeof value === "object") {
const jsonStr = TWUIEJSON.stringify(value);
queryArr.push(`${key}=${encodeURIComponent(String(jsonStr))}`);
} else if (typeof value === "string" || typeof value === "number") {
queryArr.push(`${key}=${encodeURIComponent(value)}`);
} else {
queryArr.push(`${key}=${String(value)}`);
}
});
str += queryArr.join("&");
return str;
}

View File

@ -31,7 +31,6 @@ export default function twuiSlugify(
return finalStr.replace(/-$/, ""); return finalStr.replace(/-$/, "");
} catch (error: any) { } catch (error: any) {
console.log(`Slugify ERROR: ${error.message}`);
return ""; return "";
} }
} }

View File

@ -3,7 +3,7 @@ import H2 from "@/components/lib/layout/H2";
import Row from "@/components/lib/layout/Row"; import Row from "@/components/lib/layout/Row";
import Span from "@/components/lib/layout/Span"; import Span from "@/components/lib/layout/Span";
import Stack from "@/components/lib/layout/Stack"; import Stack from "@/components/lib/layout/Stack";
import { DSQL_TBENME_BLOG_POSTS } from "@/types"; import { DSQL_TBEN_ME_BLOG_POSTS } from "@/types/dsql";
import { import {
ArrowRight, ArrowRight,
ArrowUpRight, ArrowUpRight,
@ -13,7 +13,7 @@ import {
import React from "react"; import React from "react";
type Props = { type Props = {
post: DSQL_TBENME_BLOG_POSTS; post: DSQL_TBEN_ME_BLOG_POSTS;
}; };
export default function BlogPostsListCard({ post }: Props) { export default function BlogPostsListCard({ post }: Props) {

View File

@ -6,11 +6,12 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"schema-to-typedef": "bunx dsql-schema-to-typedef",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@moduletrace/buncid": "^1.0.7", "@moduletrace/buncid": "^1.0.7",
"@moduletrace/datasquirel": "^5.1.0", "@moduletrace/datasquirel": "^5.7.51",
"@moduletrace/twui": "file:./components/lib", "@moduletrace/twui": "file:./components/lib",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
@ -18,6 +19,7 @@
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next": "15.0.3", "next": "15.0.3",
"next-mdx-remote": "^5.0.0", "next-mdx-remote": "^5.0.0",
"openai": "^6.21.0",
"prism-themes": "^1.9.0", "prism-themes": "^1.9.0",
"react": "19.0.0-rc-66855b96-20241106", "react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106", "react-dom": "19.0.0-rc-66855b96-20241106",

View File

@ -2,24 +2,30 @@ import Layout from "@/layouts/main";
import Main from "@/components/pages/blog/slug"; import Main from "@/components/pages/blog/slug";
import { GetStaticPaths, GetStaticProps } from "next"; import { GetStaticPaths, GetStaticProps } from "next";
import datasquirel from "@moduletrace/datasquirel"; import datasquirel from "@moduletrace/datasquirel";
import { DSQL_TBENME_BLOG_POSTS, PagePropsType } from "@/types"; import { PagePropsType } from "@/types";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types"; import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import { serialize } from "next-mdx-remote/serialize"; import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus"; import rehypePrismPlus from "rehype-prism-plus";
import matter from "gray-matter"; import matter from "gray-matter";
import { DSQL_TBEN_ME_BLOG_POSTS } from "@/types/dsql";
export default function SingleBlogPost() { export default function SingleBlogPost({ blogPost }: PagePropsType) {
return ( return (
<Layout> <Layout
meta={{
title: blogPost?.meta_title,
description: blogPost?.meta_description,
}}
>
<Main /> <Main />
</Layout> </Layout>
); );
} }
export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => { export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
const blogPostRes: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> = const blogPostRes: APIResponseObject<DSQL_TBEN_ME_BLOG_POSTS> =
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({ await datasquirel.crud<DSQL_TBEN_ME_BLOG_POSTS>({
action: "get", action: "get",
table: "blog_posts", table: "blog_posts",
query: { query: {
@ -67,8 +73,8 @@ export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
}; };
export const getStaticPaths: GetStaticPaths = async (ctx) => { export const getStaticPaths: GetStaticPaths = async (ctx) => {
const blogPostRes: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> = const blogPostRes: APIResponseObject<DSQL_TBEN_ME_BLOG_POSTS> =
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({ await datasquirel.crud<DSQL_TBEN_ME_BLOG_POSTS>({
action: "get", action: "get",
table: "blog_posts", table: "blog_posts",
query: { query: {

View File

@ -2,8 +2,9 @@ import Layout from "@/layouts/main";
import Main from "@/components/pages/blog"; import Main from "@/components/pages/blog";
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import datasquirel from "@moduletrace/datasquirel"; import datasquirel from "@moduletrace/datasquirel";
import { DSQL_TBENME_BLOG_POSTS, PagePropsType } from "@/types"; import { PagePropsType } from "@/types";
import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types"; import { APIResponseObject } from "@moduletrace/datasquirel/dist/package-shared/types";
import { DSQL_TBEN_ME_BLOG_POSTS } from "@/types/dsql";
export default function BlogPage() { export default function BlogPage() {
return ( return (
@ -14,8 +15,8 @@ export default function BlogPage() {
} }
export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => { export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
const blogPosts: APIResponseObject<DSQL_TBENME_BLOG_POSTS[]> = const blogPosts: APIResponseObject<DSQL_TBEN_ME_BLOG_POSTS> =
await datasquirel.crud<DSQL_TBENME_BLOG_POSTS>({ await datasquirel.crud<DSQL_TBEN_ME_BLOG_POSTS>({
action: "get", action: "get",
table: "blog_posts", table: "blog_posts",
query: { query: {

View File

@ -1,23 +1,8 @@
import { MDXRemoteSerializeResult } from "next-mdx-remote"; import { MDXRemoteSerializeResult } from "next-mdx-remote";
import { DSQL_TBEN_ME_BLOG_POSTS } from "./types/dsql";
export type PagePropsType = { export type PagePropsType = {
blogPosts?: DSQL_TBENME_BLOG_POSTS[] | null; blogPosts?: DSQL_TBEN_ME_BLOG_POSTS[] | null;
blogPost?: DSQL_TBENME_BLOG_POSTS | null; blogPost?: DSQL_TBEN_ME_BLOG_POSTS | null;
mdxSource?: MDXRemoteSerializeResult<any, any> | null; mdxSource?: MDXRemoteSerializeResult<any, any> | null;
}; };
export type DSQL_TBENME_BLOG_POSTS = {
id?: number;
title?: string;
slug?: string;
excerpt?: string;
body?: string;
metadata?: string;
published?: 0 | 1;
date_created?: string;
date_created_code?: number;
date_created_timestamp?: string;
date_updated?: string;
date_updated_code?: number;
date_updated_timestamp?: string;
};

55
types/dsql.ts Normal file
View File

@ -0,0 +1,55 @@
export const DsqlTables = [
"blog_posts",
"portfolio",
"documents",
] as const
export type DSQL_TBEN_ME_BLOG_POSTS = {
id?: number;
title?: string;
slug?: string;
excerpt?: string;
body?: string;
metadata?: string;
published?: 0 | 1;
meta_title?: string;
meta_description?: string;
date_created?: string;
date_created_code?: number;
date_created_timestamp?: string;
date_updated?: string;
date_updated_code?: number;
date_updated_timestamp?: string;
}
export type DSQL_TBEN_ME_PORTFOLIO = {
id?: number;
title?: string;
description?: string;
url?: string;
image?: string;
full_description?: string;
starting_date?: string;
completion_date?: string;
project_order?: number;
date_created?: string;
date_created_code?: number;
date_created_timestamp?: string;
date_updated?: string;
date_updated_code?: number;
date_updated_timestamp?: string;
}
export type DSQL_TBEN_ME_DOCUMENTS = {
id?: number;
project_name?: string;
html?: string;
date_created?: string;
date_created_code?: number;
date_created_timestamp?: string;
date_updated?: string;
date_updated_code?: number;
date_updated_timestamp?: string;
}
export type DSQL_TBEN_ME_ALL_TYPEDEFS = DSQL_TBEN_ME_BLOG_POSTS & DSQL_TBEN_ME_PORTFOLIO & DSQL_TBEN_ME_DOCUMENTS