This commit is contained in:
Benjamin Toby 2025-10-02 08:16:11 +01:00
parent 6d833c7d3b
commit 979728e6c8
30 changed files with 695 additions and 100 deletions

0
bun.lockb Executable file → Normal file
View File

View File

@ -1,4 +1,4 @@
import React, { DetailedHTMLProps, HTMLAttributes } from "react"; import React from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import Button from "../layout/Button"; import Button from "../layout/Button";
@ -25,6 +25,8 @@ export default function ModalComponent({ open, setOpen, ...props }: Props) {
"flex flex-col items-center justify-center p-4", "flex flex-col items-center justify-center p-4",
"twui-modal-root" "twui-modal-root"
)} )}
role="dialog"
aria-modal="true"
> >
<div <div
className={twMerge( className={twMerge(

View File

@ -80,6 +80,8 @@ export default function PopoverComponent({
onMouseLeave={ onMouseLeave={
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
} }
role="dialog"
aria-modal="true"
> >
{/* <div {/* <div
className="absolute w-0 h-0 border-8 border-transparent bg-white" className="absolute w-0 h-0 border-8 border-transparent bg-white"

View File

@ -2,6 +2,8 @@
@theme inline { @theme inline {
--breakpoint-xs: 350px; --breakpoint-xs: 350px;
--breakpoint-xxs: 300px;
--breakpoint-xxl: 1600px;
--color-background-light: #ffffff; --color-background-light: #ffffff;
--color-foreground-light: #171717; --color-foreground-light: #171717;

View File

@ -69,7 +69,13 @@ export default function AceEditor({
theme: darkMode theme: darkMode
? "ace/theme/tomorrow_night_eighties" ? "ace/theme/tomorrow_night_eighties"
: "ace/theme/ace_light", : "ace/theme/ace_light",
value: content, value: (() => {
try {
return JSON.stringify(JSON.parse(content), null, 4);
} catch (error) {
return content;
}
})(),
placeholder: placeholder ? placeholder : "", placeholder: placeholder ? placeholder : "",
enableBasicAutocompletion: true, enableBasicAutocompletion: true,
enableLiveAutocompletion: true, enableLiveAutocompletion: true,
@ -108,7 +114,7 @@ export default function AceEditor({
return function () { return function () {
editor.destroy(); editor.destroy();
}; };
}, [refresh, darkMode, ready, externalRefresh]); }, [refresh, darkMode, ready, externalRefresh, content]);
React.useEffect(() => { React.useEffect(() => {
const htmlClassName = document.documentElement.className; const htmlClassName = document.documentElement.className;

View File

@ -84,7 +84,7 @@ export default function TinyMCEEditor<KeyType extends string>({
if (defaultValue) editor.setContent(defaultValue); if (defaultValue) editor.setContent(defaultValue);
setReady(true); setReady(true);
editor.on("input", (e) => { editor.on("change", (e) => {
changeHandler?.(editor.getContent()); changeHandler?.(editor.getContent());
}); });
@ -92,7 +92,7 @@ export default function TinyMCEEditor<KeyType extends string>({
useParentStyles(editor); useParentStyles(editor);
} }
}, },
base_url: "https://datasquirel.com/tinymce-public", base_url: "https://www.datasquirel.com/tinymce-public",
body_class: "twui-tinymce", body_class: "twui-tinymce",
placeholder, placeholder,
relative_urls: true, relative_urls: true,
@ -118,6 +118,9 @@ export default function TinyMCEEditor<KeyType extends string>({
"bg-background-light dark:bg-background-dark", "bg-background-light dark:bg-background-dark",
wrapperWrapperProps?.className wrapperWrapperProps?.className
)} )}
onInput={(e) => {
console.log(`Input Detected`);
}}
> >
{showLabel && ( {showLabel && (
<label <label

View File

@ -16,7 +16,8 @@ export default function useTinyMCE() {
} }
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://datasquirel.com/tinymce-public/tinymce.min.js"; script.src =
"https://www.datasquirel.com/tinymce-public/tinymce.min.js";
script.async = true; script.async = true;
document.head.appendChild(script); document.head.appendChild(script);

View File

@ -40,7 +40,7 @@ export default function Card({
ref={elRef} ref={elRef}
{...props} {...props}
className={twMerge( className={twMerge(
"flex flex-row items-center p-4 rounded-default bg-white dark:bg-white/10", "flex flex-row items-center p-4 rounded-default bg-background-light dark:bg-background-dark",
"border border-slate-200 dark:border-white/10 border-solid", "border border-slate-200 dark:border-white/10 border-solid",
noHover ? "" : "twui-card", noHover ? "" : "twui-card",
props.className props.className

View File

@ -0,0 +1,59 @@
import React, {
ComponentProps,
Dispatch,
ReactNode,
SetStateAction,
} from "react";
import { Copy, LucideProps } from "lucide-react";
import Button from "../layout/Button";
type Props = Omit<ComponentProps<typeof Button>, "title"> & {
slugText: string;
justIcon?: boolean;
noIcon?: boolean;
title?: string;
outlined?: boolean;
successMsg?: string | ReactNode;
icon?: ReactNode;
iconProps?: LucideProps;
setToastOpen?: Dispatch<SetStateAction<boolean>>;
};
export default function CopySlug({
slugText,
justIcon,
noIcon,
title,
outlined,
successMsg,
iconProps,
icon,
setToastOpen,
...props
}: Props) {
return (
<Button
title={title || slugText}
size="smaller"
variant="ghost"
color="gray"
{...props}
onClick={(e) => {
navigator.clipboard.writeText(slugText).then(() => {
setToastOpen?.(false);
setTimeout(() => {
setToastOpen?.(true);
}, 100);
});
props.onClick?.(e);
}}
style={{ ...(outlined ? {} : { padding: 0 }), ...props.style }}
>
{noIcon
? null
: icon || <Copy size={outlined ? 15 : 20} {...iconProps} />}
{!justIcon && (title ? title : "Copy Slug")}
</Button>
);
}

View File

@ -103,8 +103,8 @@ export default function HeaderNavLinkComponent({
<Dropdown <Dropdown
target={mainLinkComponent} target={mainLinkComponent}
position="bottom-right" position="center"
// hoverOpen hoverOpen
className="hidden xl:flex" className="hidden xl:flex"
> >
{dropdown ? ( {dropdown ? (

View File

@ -130,7 +130,8 @@ export default function LinkList({
{...link.linkProps} {...link.linkProps}
className={twMerge( className={twMerge(
"p-2 cursor-pointer whitespace-nowrap", "p-2 cursor-pointer whitespace-nowrap",
linkProps?.className linkProps?.className,
link.linkProps?.className
)} )}
strict={link.strict} strict={link.strict}
onClick={(e) => { onClick={(e) => {

View File

@ -23,7 +23,7 @@ export default function Paper({
{...props} {...props}
ref={componentRef as any} ref={componentRef as any}
className={twMerge( className={twMerge(
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4", "flex flex-col items-start p-4 rounded bg-background-light dark:bg-background-dark gap-4",
"border border-slate-200 dark:border-white/10 border-solid w-full", "border border-slate-200 dark:border-white/10 border-solid w-full",
"relative", "relative",
"twui-paper", "twui-paper",

View File

@ -39,7 +39,9 @@ export default function Search<KeyType extends string>({
placeholder, placeholder,
...props ...props
}: SearchProps<KeyType>) { }: SearchProps<KeyType>) {
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState(
props.defaultValue?.toString() || ""
);
React.useEffect(() => { React.useEffect(() => {
clearTimeout(timeout); clearTimeout(timeout);

View File

@ -26,6 +26,7 @@ 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;
}; };
/** /**
@ -43,6 +44,7 @@ export default function Tabs({
debounce = 100, debounce = 100,
switchComponent, switchComponent,
setActiveValue: existingSetActiveValue, setActiveValue: existingSetActiveValue,
changeHandler,
...props ...props
}: TWUI_TOGGLE_PROPS) { }: TWUI_TOGGLE_PROPS) {
const finalTabsContentArray = tabsContentArray const finalTabsContentArray = tabsContentArray
@ -70,6 +72,9 @@ export default function Tabs({
React.useEffect(() => { React.useEffect(() => {
existingSetActiveValue?.(activeValue); existingSetActiveValue?.(activeValue);
if (targetContent && activeValue) {
changeHandler?.(targetContent);
}
}, [activeValue]); }, [activeValue]);
return ( return (

View File

@ -51,7 +51,7 @@ export default function Tag({
? "bg-orange-700 outline-orange-700" ? "bg-orange-700 outline-orange-700"
: color == "gray" : color == "gray"
? twMerge( ? twMerge(
"bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20", "bg-slate-100 outline-slate-200 dark:bg-gray-dark dark:outline-gray-dark",
"text-slate-800 dark:text-white" "text-slate-800 dark:text-white"
) )
: "bg-primary text-white outline-primbg-primary twui-tag-primary", : "bg-primary text-white outline-primbg-primary twui-tag-primary",

View File

@ -0,0 +1,81 @@
import { Send, X } from "lucide-react";
import React, { Dispatch, SetStateAction } from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import Row from "../../layout/Row";
import Button from "../../layout/Button";
import CopySlug from "../CopySlug";
import AIPromptHistoryModal from "./AIPromptHistoryModal";
type Props = {
streamRes: string;
setStreamRes: Dispatch<SetStateAction<string>>;
setPrompt: Dispatch<SetStateAction<string>>;
loading: boolean;
promptFn: (prompt: string) => void;
history: ChatCompletionMessageParam[];
prompt: string;
currentPromptRef: React.MutableRefObject<string>;
promptInputRef: React.RefObject<HTMLTextAreaElement>;
};
export default function AIPromptActionSection({
streamRes,
setStreamRes,
loading,
promptFn,
history,
prompt,
setPrompt,
currentPromptRef,
promptInputRef,
}: Props) {
return (
<Row className="w-full justify-between">
<Row className="gap-4">
{streamRes.match(/./) && (
<React.Fragment>
<Button
title="Clear AI Result"
variant="ghost"
size="smaller"
color="gray"
className="px-0"
beforeIcon={<X size={20} />}
onClick={() => {
setStreamRes("");
}}
/>
<CopySlug
slugText={streamRes}
justIcon
iconProps={{ size: 18 }}
title="Copy Content"
/>
</React.Fragment>
)}
</Row>
<Row>
<AIPromptHistoryModal history={history} />
</Row>
<Row>
<Button
title="Send Prompt"
beforeIcon={<Send size={20} />}
loading={loading}
className="p-2"
onClick={() => {
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}}
loadingProps={{ size: "smaller" }}
/>
</Row>
</Row>
);
}

View File

@ -0,0 +1,98 @@
import { ChatCompletionMessageParam } from "openai/resources/index";
import React from "react";
import Paper from "../Paper";
import Stack from "../../layout/Stack";
import AIPromptPreview from "./AIPromptPreview";
import LoadingOverlay from "../LoadingOverlay";
import Textarea from "../../form/Textarea";
import AIPromptActionSection from "./AIPromptActionSection";
import Card from "../Card";
import Row from "../../layout/Row";
import Span from "../../layout/Span";
import { MessageCircleMore } from "lucide-react";
type Props = {
model?: string;
promptFn: (prompt: string) => void;
history?: ChatCompletionMessageParam[];
loading?: boolean;
mdRes?: string;
setMdRes: React.Dispatch<React.SetStateAction<string>>;
};
export default function AIPromptBlock({
model,
promptFn,
history = [],
loading = false,
mdRes = "",
setMdRes,
}: Props) {
const [prompt, setPrompt] = React.useState("");
const currentPromptRef = React.useRef("");
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
return (
<Paper className="">
<Stack className="w-full">
{currentPromptRef.current && (
<Row className="w-full justify-end">
<Card className="py-1.5 px-2.5 text-xs">
<Row>
<Span>{currentPromptRef.current}</Span>
<MessageCircleMore
size={15}
opacity={0.5}
className="-mt-[1px]"
/>
</Row>
</Card>
</Row>
)}
<AIPromptPreview
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
/>
<Stack className="w-full relative">
{loading && <LoadingOverlay />}
<Textarea
placeholder={model ? `Prompt ${model}` : "Prompt AI"}
wrapperProps={{ className: "outline-none" }}
wrapperWrapperProps={{ className: "w-full" }}
value={prompt}
onChange={(e) => {
setPrompt(e.target.value);
}}
onKeyDown={(e) => {
if (e.key == "Enter" && !e.ctrlKey) {
e.preventDefault();
currentPromptRef.current = prompt;
setTimeout(() => {
setPrompt("");
if (promptInputRef.current) {
promptInputRef.current.value = "";
}
}, 200);
promptFn(prompt);
}
}}
componentRef={promptInputRef}
autoFocus
/>
<AIPromptActionSection
loading={loading}
promptFn={promptFn}
setStreamRes={setMdRes}
streamRes={mdRes}
history={history}
prompt={prompt}
setPrompt={setPrompt}
currentPromptRef={currentPromptRef}
promptInputRef={promptInputRef as any}
/>
</Stack>
</Stack>
</Paper>
);
}

View File

@ -0,0 +1,99 @@
import React from "react";
import { ChatCompletionMessageParam } from "openai/resources/index";
import { twMerge } from "tailwind-merge";
import { Bot, User } from "lucide-react";
import Modal from "../Modal";
import Button from "../../layout/Button";
import Stack from "../../layout/Stack";
import H2 from "../../layout/H2";
import Span from "../../layout/Span";
import Divider from "../../layout/Divider";
import Row from "../../layout/Row";
import Card from "../Card";
import Border from "../Border";
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
type Props = {
history: ChatCompletionMessageParam[];
};
export default function AIPromptHistoryModal({ history }: Props) {
if (!history[0]) return null;
return (
<Modal
target={
<Button
title="View Chat History"
size="smaller"
color="gray"
variant="outlined"
>
View History
</Button>
}
className="max-w-[900px] bg-slate-100 dark:bg-white/5 xl:p-10"
>
<Stack className="gap-10 w-full">
<Stack className="gap-1">
<H2 className="!text-xl m-0">Chat History</H2>
<Span className="text-xs">
AI chat history for this session.
</Span>
</Stack>
<Divider />
{history.map((hst, index) => {
if (hst.role == "user") {
return (
<Row
key={index}
className="w-full items-start justify-end"
>
<Card
className={twMerge(
"bg-background-dark text-foreground-dark dark:!bg-background-light dark:text-foreground-light"
)}
>
{hst.content?.toString()}
</Card>
<Border className="w-10 h-10 rounded-full p-2 items-center justify-center">
<User />
</Border>
</Row>
);
}
return (
<Row
key={index}
className="w-full items-start flex-nowrap"
>
<Stack>
<Border
className={twMerge(
"w-10 h-10 rounded-full items-center justify-center bg-white p-2",
"dark:bg-background-dark"
)}
>
<Bot />
</Border>
</Stack>
<Card className="grow overflow-x-auto xl:p-8">
<MarkdownEditorPreviewComponent
value={hst.content?.toString() || ""}
maxHeight="none"
wrapperProps={{
className:
"border-none p-0 ai-response-content w-full",
}}
/>
</Card>
</Row>
);
})}
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,51 @@
import Divider from "@/src/components/twui/layout/Divider";
import Stack from "@/src/components/twui/layout/Stack";
import React from "react";
import MarkdownEditorPreviewComponent from "@/src/components/twui/mdx/markdown/MarkdownEditorPreviewComponent";
import { ChatCompletionMessageParam } from "openai/resources/index";
type Props = {
streamRes: string;
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
history: ChatCompletionMessageParam[];
};
export default function AIPromptPreview({
setStreamRes,
streamRes,
history,
}: Props) {
const responseContentRef = React.useRef<HTMLDivElement>(null);
const isContentInterrupted = React.useRef(false);
React.useEffect(() => {
if (isContentInterrupted.current) return;
if (responseContentRef.current) {
responseContentRef.current.scrollTop =
responseContentRef.current.scrollHeight;
}
}, [streamRes]);
if (!streamRes?.match(/./)) return null;
return (
<Stack className="w-full">
<MarkdownEditorPreviewComponent
value={streamRes}
maxHeight="40vh"
wrapperProps={{
className: "border-none p-0 ai-response-content",
componentRef: responseContentRef as any,
onMouseEnter: () => {
isContentInterrupted.current = true;
},
onMouseLeave: () => {
isContentInterrupted.current = false;
},
}}
/>
<Divider />
</Stack>
);
}

View File

@ -11,10 +11,14 @@ import Row from "../layout/Row";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
import Span from "../layout/Span"; import Span from "../layout/Span";
export type CheckboxProps = React.DetailedHTMLProps< export type CheckboxProps = Omit<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
>,
"title"
> & { > & {
title?: string | ReactNode;
wrapperProps?: DetailedHTMLProps< wrapperProps?: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>, HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
@ -29,6 +33,7 @@ export type CheckboxProps = React.DetailedHTMLProps<
setChecked?: React.Dispatch<React.SetStateAction<boolean>>; setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
checked?: boolean; checked?: boolean;
readOnly?: boolean; readOnly?: boolean;
noLabel?: boolean;
size?: number; size?: number;
changeHandler?: (value: boolean) => void; changeHandler?: (value: boolean) => void;
info?: string | ReactNode; info?: string | ReactNode;
@ -54,6 +59,8 @@ export default function Checkbox({
changeHandler, changeHandler,
info, info,
wrapperWrapperProps, wrapperWrapperProps,
noLabel,
title,
...props ...props
}: CheckboxProps) { }: CheckboxProps) {
const finalSize = size || 20; const finalSize = size || 20;
@ -62,8 +69,8 @@ export default function Checkbox({
defaultChecked || externalChecked || false defaultChecked || externalChecked || false
); );
const finalTitle = props.title const finalTitle = title
? props.title ? title
: `Checkbox-${Math.round(Math.random() * 100000)}`; : `Checkbox-${Math.round(Math.random() * 100000)}`;
React.useEffect(() => { React.useEffect(() => {
@ -112,6 +119,7 @@ export default function Checkbox({
> >
{checked && <CheckMarkSVG />} {checked && <CheckMarkSVG />}
</div> </div>
{!noLabel && (
<Stack className="gap-0.5"> <Stack className="gap-0.5">
<div <div
{...labelProps} {...labelProps}
@ -123,6 +131,7 @@ export default function Checkbox({
{label || finalTitle} {label || finalTitle}
</div> </div>
</Stack> </Stack>
)}
</div> </div>
{info && ( {info && (
<Row className="gap-1" title={info.toString()}> <Row className="gap-1" title={info.toString()}>

View File

@ -12,12 +12,23 @@ import Row from "../layout/Row";
import Input from "./Input"; import Input from "./Input";
import Loading from "../elements/Loading"; import Loading from "../elements/Loading";
type FileInputUtils = {
clearFileInput?: () => void;
};
type ImageUploadProps = DetailedHTMLProps< type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
> & { > & {
onChangeHandler?: ( onChangeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils
) => any;
changeHandler?: (
fileData: FileInputToBase64FunctionReturn | undefined,
inputRef?: React.RefObject<HTMLInputElement | null>,
utils?: FileInputUtils
) => any; ) => any;
onClear?: () => void; onClear?: () => void;
fileInputProps?: DetailedHTMLProps< fileInputProps?: DetailedHTMLProps<
@ -74,6 +85,7 @@ export default function FileUpload({
loading, loading,
multiple, multiple,
onClear, onClear,
changeHandler,
...props ...props
}: ImageUploadProps) { }: ImageUploadProps) {
const [file, setFile] = React.useState< const [file, setFile] = React.useState<
@ -98,6 +110,19 @@ export default function FileUpload({
} }
}, [existingFile]); }, [existingFile]);
function clearFileInput() {
setFile(undefined);
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
changeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}
const fileInputUtils: FileInputUtils = { clearFileInput };
return ( return (
<Stack <Stack
{...props} {...props}
@ -136,7 +161,12 @@ export default function FileUpload({
(res) => { (res) => {
setFile(res); setFile(res);
externalSetFile?.(res); externalSetFile?.(res);
onChangeHandler?.(res); onChangeHandler?.(
res,
inputRef,
fileInputUtils
);
changeHandler?.(res, inputRef, fileInputUtils);
fileInputProps?.onChange?.(e); fileInputProps?.onChange?.(e);
} }
); );
@ -191,13 +221,7 @@ export default function FileUpload({
"hover:bg-white dark:hover:bg-black" "hover:bg-white dark:hover:bg-black"
)} )}
onClick={(e) => { onClick={(e) => {
setFile(undefined); clearFileInput();
externalSetFile?.(undefined);
onChangeHandler?.(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
onClear?.();
}} }}
title="Cancel File Upload Button" title="Cancel File Upload Button"
> >
@ -250,6 +274,7 @@ export default function FileUpload({
setFile(undefined); setFile(undefined);
externalSetFile?.(undefined); externalSetFile?.(undefined);
onChangeHandler?.(undefined); onChangeHandler?.(undefined);
changeHandler?.(undefined);
setFileUrl(undefined); setFileUrl(undefined);
}} }}
title="Cancel File Button" title="Cancel File Button"
@ -300,6 +325,7 @@ export default function FileUpload({
setFile(res); setFile(res);
externalSetFile?.(res); externalSetFile?.(res);
onChangeHandler?.(res); onChangeHandler?.(res);
changeHandler?.(res);
} }
); );
}} }}

View File

@ -75,6 +75,10 @@ export type InputProps<KeyType extends string> = DetailedHTMLProps<
info?: string | ReactNode; info?: string | ReactNode;
ready?: boolean; ready?: boolean;
validity?: TWUISelectValidityObject; validity?: TWUISelectValidityObject;
clearInputProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
}; };
let refreshes = 0; let refreshes = 0;
@ -114,6 +118,7 @@ export default function Input<KeyType extends string>(
info, info,
changeHandler, changeHandler,
validity: existingValidity, validity: existingValidity,
clearInputProps,
...props ...props
} = inputProps; } = inputProps;
@ -256,7 +261,7 @@ export default function Input<KeyType extends string>(
} }
{...props} {...props}
className={twMerge( className={twMerge(
"w-full outline-none bg-transparent", "w-full outline-none bg-transparent grow",
"twui-textarea", "twui-textarea",
props.className props.className
)} )}
@ -285,7 +290,7 @@ export default function Input<KeyType extends string>(
"w-full outline-none bg-transparent border-none", "w-full outline-none bg-transparent border-none",
"hover:border-none hover:outline-none focus:border-none focus:outline-none", "hover:border-none hover:outline-none focus:border-none focus:outline-none",
"dark:bg-transparent dark:outline-none dark:border-none", "dark:bg-transparent dark:outline-none dark:border-none",
"p-0", "p-0 grow",
"twui-input", "twui-input",
props.className props.className
)} )}
@ -301,6 +306,7 @@ export default function Input<KeyType extends string>(
onChange={handleValueChange} onChange={handleValueChange}
type={inputType} type={inputType}
defaultValue={defaultInitialValue} defaultValue={defaultInitialValue}
autoComplete={autoComplete}
value={props.value ? getFinalValue(props.value) : undefined} value={props.value ? getFinalValue(props.value) : undefined}
/> />
); );
@ -381,10 +387,12 @@ export default function Input<KeyType extends string>(
{props.type == "search" || props.readOnly ? null : ( {props.type == "search" || props.readOnly ? null : (
<div <div
title="Clear Input Field" title="Clear Input Field"
{...clearInputProps}
className={twMerge( className={twMerge(
"p-1 -my-2 -mx-1 opacity-0 cursor-pointer", "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
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -397,9 +405,10 @@ export default function Input<KeyType extends string>(
} }
updateValue(""); updateValue("");
clearInputProps?.onClick?.(e);
}} }}
> >
<X size={15} /> <X className="w-full h-full" />
</div> </div>
)} )}

View File

@ -0,0 +1,79 @@
import {
ComponentProps,
DetailedHTMLProps,
InputHTMLAttributes,
LabelHTMLAttributes,
} from "react";
import Row from "../layout/Row";
import twuiSlugify from "../utils/slugify";
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
import { twMerge } from "tailwind-merge";
type Value = {
value: string;
title?: string;
default?: boolean;
};
export type TWUI_FORM_RADIO_PROPS = {
values: Value[];
name: string;
inputProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>;
wrapperProps?: ComponentProps<typeof Row>;
changeHandler?: (value: string) => void;
};
/**
* # Form Radios Component
* @className twui-textarea
*/
export default function Radios({
values,
name,
inputProps,
labelProps,
wrapperProps,
changeHandler,
}: TWUI_FORM_RADIO_PROPS) {
const finalName = twuiSlugify(name);
const finalTitle = twuiSlugToNormalText(finalName);
return (
<Row
title={finalTitle}
{...wrapperProps}
className={twMerge("gap-4", wrapperProps?.className)}
>
{values.map((v, i) => {
const valueName = twuiSlugify(`${finalName}-${v.value}`);
const valueTitle = v.title || twuiSlugToNormalText(v.value);
return (
<Row key={i} className="gap-1.5">
<input
id={valueName}
type="radio"
defaultChecked={v.default}
name={finalName}
onChange={(e) => {
const targetValue = v.value;
changeHandler?.(targetValue);
}}
{...inputProps}
/>
<label htmlFor={valueName} {...labelProps}>
{valueTitle}
</label>
</Row>
);
})}
</Row>
);
}

View File

@ -47,17 +47,23 @@ export default function SearchSelect<
const [currentOptions, setCurrentOptions] = const [currentOptions, setCurrentOptions] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(options); React.useState<TWUISelectOptionObject<KeyType, T>[]>(options);
const defaultOption = options.find((opt) => opt.default) || options[0]; const defaultOption = (options.find((opt) => opt.default) || options[0]) as
| TWUISelectOptionObject<KeyType, T>
| undefined;
const [value, setValue] = React.useState< const [value, setValue] = React.useState<
TWUISelectOptionObject<KeyType, T> TWUISelectOptionObject<KeyType, T> | undefined
>({ >(
value: defaultOption.value, defaultOption
data: defaultOption.data, ? {
}); value: defaultOption?.value,
data: defaultOption?.data,
}
: undefined
);
const [inputValue, setInputValue] = React.useState<string>( const [inputValue, setInputValue] = React.useState<string>(
defaultOption.value defaultOption?.value || ""
); );
const [selectIndex, setSelectIndex] = React.useState<number | undefined>(); const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
@ -91,11 +97,14 @@ export default function SearchSelect<
}, [open]); }, [open]);
React.useEffect(() => { React.useEffect(() => {
if (value) {
dispatchState?.(value.data); dispatchState?.(value.data);
setInputValue(value.value); setInputValue(value.value);
changeHandler?.(value.value);
}
clearTimeout(focusTimeout); clearTimeout(focusTimeout);
setOpen(false); setOpen(false);
changeHandler?.(value.value);
setSelectIndex(undefined); setSelectIndex(undefined);
}, [value]); }, [value]);
@ -246,7 +255,7 @@ export default function SearchSelect<
targetWrapperProps={{ className: "w-full" }} targetWrapperProps={{ className: "w-full" }}
contentWrapperProps={{ className: "w-full" }} contentWrapperProps={{ className: "w-full" }}
className="w-full" className="w-full"
externalOpen={open} externalOpen={currentOptions?.[0] && open}
> >
<Paper <Paper
className={twMerge( className={twMerge(
@ -255,7 +264,8 @@ export default function SearchSelect<
componentRef={contentWrapperRef} componentRef={contentWrapperRef}
> >
<Stack className="w-full items-start gap-0"> <Stack className="w-full items-start gap-0">
{currentOptions.map((_o, index) => { {currentOptions?.[0]
? currentOptions.map((_o, index) => {
const isTargetOption = index === selectIndex; const isTargetOption = index === selectIndex;
const targetOptionClasses = twMerge( const targetOptionClasses = twMerge(
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light", "bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
@ -285,7 +295,8 @@ export default function SearchSelect<
<Divider /> <Divider />
</React.Fragment> </React.Fragment>
); );
})} })
: null}
</Stack> </Stack>
</Paper> </Paper>
</Dropdown> </Dropdown>

View File

@ -25,8 +25,8 @@ export type TWUISelectValidityObject = {
}; };
export type TWUISelectOptionObject< export type TWUISelectOptionObject<
KeyType extends string, KeyType extends string = string,
T extends { [k: string]: any } = any T extends { [k: string]: any } = { [k: string]: any }
> = { > = {
title?: string; title?: string;
value: KeyType; value: KeyType;
@ -36,7 +36,7 @@ export type TWUISelectOptionObject<
export type TWUISelectProps< export type TWUISelectProps<
KeyType extends string, KeyType extends string,
T extends { [k: string]: any } = any T extends { [k: string]: any } = { [k: string]: any }
> = DetailedHTMLProps< > = DetailedHTMLProps<
SelectHTMLAttributes<HTMLSelectElement>, SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement HTMLSelectElement
@ -168,6 +168,7 @@ export default function Select<
<select <select
id={selectID} id={selectID}
aria-label={props["aria-label"] || props.title}
{...props} {...props}
className={twMerge( className={twMerge(
"w-full pl-3 py-2 rounded-default appearance-none pr-8", "w-full pl-3 py-2 rounded-default appearance-none pr-8",

View File

@ -73,7 +73,10 @@ export default function useWebSocket<
* # Connect to Websocket * # Connect to Websocket
*/ */
const connect = React.useCallback(() => { const connect = React.useCallback(() => {
const wsURL = url; const domain = window.location.origin;
const wsURL = url.startsWith(`ws`)
? url
: domain.replace(/^http/, "ws") + ("/" + url).replace(/\/\//g, "/");
if (!wsURL) return; if (!wsURL) return;
let ws = new WebSocket(wsURL); let ws = new WebSocket(wsURL);

View File

@ -18,9 +18,10 @@ export default function useWebSocketEventHandler<
const dataEventListenerCallback = (e: Event) => { const dataEventListenerCallback = (e: Event) => {
const customEvent = e as CustomEvent; const customEvent = e as CustomEvent;
const data = customEvent.detail.data as T | undefined; const data = customEvent.detail.data as T | undefined;
const message = customEvent.detail.message as string | undefined; const __msg = customEvent.detail.message as string | undefined;
if (data) setData(data); if (data) setData(data);
if (message) setMessage(message); if (__msg && typeof __msg == "string") setMessage(__msg);
}; };
const messageEventName: (typeof WebSocketEventNames)[number] = const messageEventName: (typeof WebSocketEventNames)[number] =

View File

@ -1,5 +1,5 @@
import _ from "lodash"; import _ from "lodash";
import React, { DetailedHTMLProps, ImgHTMLAttributes } from "react"; import React, { DetailedHTMLProps, ImgHTMLAttributes, ReactNode } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export type TWUIImageProps = DetailedHTMLProps< export type TWUIImageProps = DetailedHTMLProps<
@ -14,13 +14,23 @@ export type TWUIImageProps = DetailedHTMLProps<
fallbackImageSrc?: string; fallbackImageSrc?: string;
srcLight?: string; srcLight?: string;
srcDark?: string; srcDark?: string;
imgErrSrc?: string;
imgErrComp?: ReactNode;
imgErrSrcLight?: string;
imgErrSrcDark?: string;
}; };
/** /**
* # Image Component * # Image Component
* @className twui-img * @className twui-img
*/ */
export default function Img({ ...props }: TWUIImageProps) { export default function Img({
imgErrSrc,
imgErrComp,
imgErrSrcDark,
imgErrSrcLight,
...props
}: TWUIImageProps) {
const width = props.size || props.width; const width = props.size || props.width;
const height = props.size || props.height; const height = props.size || props.height;
const sizeRatio = width && height ? Number(width) / Number(height) : 1; const sizeRatio = width && height ? Number(width) / Number(height) : 1;
@ -70,13 +80,16 @@ export default function Img({ ...props }: TWUIImageProps) {
if (imageError) { if (imageError) {
return ( return (
imgErrComp || (
<img <img
loading="lazy" loading="lazy"
{...interpolatedProps} {...interpolatedProps}
src={ src={
imgErrSrc ||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg" "https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
} }
/> />
)
); );
} }

View File

@ -24,6 +24,7 @@ export function useMDXComponents(params?: Params) {
if (React.isValidElement(children) && children.props) { if (React.isValidElement(children) && children.props) {
return ( return (
<CodeBlock {...props} backgroundColor={codeBgColor}> <CodeBlock {...props} backgroundColor={codeBgColor}>
{/* @ts-ignore */}
{children.props.children} {children.props.children}
</CodeBlock> </CodeBlock>
); );

View File

@ -58,6 +58,21 @@ export const work = {
Devops: { Devops: {
href: "/work/devops", href: "/work/devops",
portfolio: [ portfolio: [
{
title: "TurboCI",
description: "Cloud VPS orchestrator that runs any workload",
href: "https://turboci.tben.me",
technologies: [
"Bun",
"Shell",
"Typescript",
"APIs",
"Hetzner",
"AWS",
"GCP",
"Azure",
],
},
{ {
title: "Personal Mail Server", title: "Personal Mail Server",
description: description:
@ -81,7 +96,22 @@ export const work = {
href: "/work/devops", href: "/work/devops",
portfolio: [ portfolio: [
{ {
title: "Turbo Sync NPM Module", title: "TurboCI",
description: "Cloud VPS orchestrator that runs any workload",
href: "https://turboci.tben.me",
technologies: [
"Bun",
"Shell",
"Typescript",
"APIs",
"Hetzner",
"AWS",
"GCP",
"Azure",
],
},
{
title: "Turbo Sync",
description: description:
"The easiest way to synchronize local and remote directories in real time", "The easiest way to synchronize local and remote directories in real time",
href: "https://git.tben.me/Moduletrace/turbo-sync", href: "https://git.tben.me/Moduletrace/turbo-sync",