Updates
This commit is contained in:
parent
6d833c7d3b
commit
979728e6c8
@ -1,4 +1,4 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import ReactDOM from "react-dom";
|
||||
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",
|
||||
"twui-modal-root"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
|
@ -80,6 +80,8 @@ export default function PopoverComponent({
|
||||
onMouseLeave={
|
||||
trigger === "hover" ? popoverLeaveFn : props.onMouseLeave
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* <div
|
||||
className="absolute w-0 h-0 border-8 border-transparent bg-white"
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
@theme inline {
|
||||
--breakpoint-xs: 350px;
|
||||
--breakpoint-xxs: 300px;
|
||||
--breakpoint-xxl: 1600px;
|
||||
|
||||
--color-background-light: #ffffff;
|
||||
--color-foreground-light: #171717;
|
||||
|
@ -69,7 +69,13 @@ export default function AceEditor({
|
||||
theme: darkMode
|
||||
? "ace/theme/tomorrow_night_eighties"
|
||||
: "ace/theme/ace_light",
|
||||
value: content,
|
||||
value: (() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 4);
|
||||
} catch (error) {
|
||||
return content;
|
||||
}
|
||||
})(),
|
||||
placeholder: placeholder ? placeholder : "",
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
@ -108,7 +114,7 @@ export default function AceEditor({
|
||||
return function () {
|
||||
editor.destroy();
|
||||
};
|
||||
}, [refresh, darkMode, ready, externalRefresh]);
|
||||
}, [refresh, darkMode, ready, externalRefresh, content]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlClassName = document.documentElement.className;
|
||||
|
@ -84,7 +84,7 @@ export default function TinyMCEEditor<KeyType extends string>({
|
||||
if (defaultValue) editor.setContent(defaultValue);
|
||||
setReady(true);
|
||||
|
||||
editor.on("input", (e) => {
|
||||
editor.on("change", (e) => {
|
||||
changeHandler?.(editor.getContent());
|
||||
});
|
||||
|
||||
@ -92,7 +92,7 @@ export default function TinyMCEEditor<KeyType extends string>({
|
||||
useParentStyles(editor);
|
||||
}
|
||||
},
|
||||
base_url: "https://datasquirel.com/tinymce-public",
|
||||
base_url: "https://www.datasquirel.com/tinymce-public",
|
||||
body_class: "twui-tinymce",
|
||||
placeholder,
|
||||
relative_urls: true,
|
||||
@ -118,6 +118,9 @@ export default function TinyMCEEditor<KeyType extends string>({
|
||||
"bg-background-light dark:bg-background-dark",
|
||||
wrapperWrapperProps?.className
|
||||
)}
|
||||
onInput={(e) => {
|
||||
console.log(`Input Detected`);
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<label
|
||||
|
@ -16,7 +16,8 @@ export default function useTinyMCE() {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
@ -40,7 +40,7 @@ export default function Card({
|
||||
ref={elRef}
|
||||
{...props}
|
||||
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",
|
||||
noHover ? "" : "twui-card",
|
||||
props.className
|
||||
|
59
components/lib/elements/CopySlug.tsx
Normal file
59
components/lib/elements/CopySlug.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -103,8 +103,8 @@ export default function HeaderNavLinkComponent({
|
||||
|
||||
<Dropdown
|
||||
target={mainLinkComponent}
|
||||
position="bottom-right"
|
||||
// hoverOpen
|
||||
position="center"
|
||||
hoverOpen
|
||||
className="hidden xl:flex"
|
||||
>
|
||||
{dropdown ? (
|
||||
|
@ -130,7 +130,8 @@ export default function LinkList({
|
||||
{...link.linkProps}
|
||||
className={twMerge(
|
||||
"p-2 cursor-pointer whitespace-nowrap",
|
||||
linkProps?.className
|
||||
linkProps?.className,
|
||||
link.linkProps?.className
|
||||
)}
|
||||
strict={link.strict}
|
||||
onClick={(e) => {
|
||||
|
@ -23,7 +23,7 @@ export default function Paper({
|
||||
{...props}
|
||||
ref={componentRef as any}
|
||||
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",
|
||||
"relative",
|
||||
"twui-paper",
|
||||
|
@ -39,7 +39,9 @@ export default function Search<KeyType extends string>({
|
||||
placeholder,
|
||||
...props
|
||||
}: SearchProps<KeyType>) {
|
||||
const [input, setInput] = React.useState("");
|
||||
const [input, setInput] = React.useState(
|
||||
props.defaultValue?.toString() || ""
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearTimeout(timeout);
|
||||
|
@ -26,6 +26,7 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
*/
|
||||
switchComponent?: ReactNode;
|
||||
setActiveValue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
changeHandler?: (value: TWUITabsObject) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -43,6 +44,7 @@ export default function Tabs({
|
||||
debounce = 100,
|
||||
switchComponent,
|
||||
setActiveValue: existingSetActiveValue,
|
||||
changeHandler,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const finalTabsContentArray = tabsContentArray
|
||||
@ -70,6 +72,9 @@ export default function Tabs({
|
||||
|
||||
React.useEffect(() => {
|
||||
existingSetActiveValue?.(activeValue);
|
||||
if (targetContent && activeValue) {
|
||||
changeHandler?.(targetContent);
|
||||
}
|
||||
}, [activeValue]);
|
||||
|
||||
return (
|
||||
|
@ -51,7 +51,7 @@ export default function Tag({
|
||||
? "bg-orange-700 outline-orange-700"
|
||||
: color == "gray"
|
||||
? 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"
|
||||
)
|
||||
: "bg-primary text-white outline-primbg-primary twui-tag-primary",
|
||||
|
81
components/lib/elements/ai/AIPromptActionSection.tsx
Normal file
81
components/lib/elements/ai/AIPromptActionSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
98
components/lib/elements/ai/AIPromptBlock.tsx
Normal file
98
components/lib/elements/ai/AIPromptBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
99
components/lib/elements/ai/AIPromptHistoryModal.tsx
Normal file
99
components/lib/elements/ai/AIPromptHistoryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
components/lib/elements/ai/AIPromptPreview.tsx
Normal file
51
components/lib/elements/ai/AIPromptPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -11,10 +11,14 @@ import Row from "../layout/Row";
|
||||
import { Info } from "lucide-react";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type CheckboxProps = React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
export type CheckboxProps = Omit<
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>,
|
||||
"title"
|
||||
> & {
|
||||
title?: string | ReactNode;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
@ -29,6 +33,7 @@ export type CheckboxProps = React.DetailedHTMLProps<
|
||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
checked?: boolean;
|
||||
readOnly?: boolean;
|
||||
noLabel?: boolean;
|
||||
size?: number;
|
||||
changeHandler?: (value: boolean) => void;
|
||||
info?: string | ReactNode;
|
||||
@ -54,6 +59,8 @@ export default function Checkbox({
|
||||
changeHandler,
|
||||
info,
|
||||
wrapperWrapperProps,
|
||||
noLabel,
|
||||
title,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
@ -62,8 +69,8 @@ export default function Checkbox({
|
||||
defaultChecked || externalChecked || false
|
||||
);
|
||||
|
||||
const finalTitle = props.title
|
||||
? props.title
|
||||
const finalTitle = title
|
||||
? title
|
||||
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -112,17 +119,19 @@ export default function Checkbox({
|
||||
>
|
||||
{checked && <CheckMarkSVG />}
|
||||
</div>
|
||||
<Stack className="gap-0.5">
|
||||
<div
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"select-none whitespace-normal md:whitespace-nowrap",
|
||||
labelProps?.className
|
||||
)}
|
||||
>
|
||||
{label || finalTitle}
|
||||
</div>
|
||||
</Stack>
|
||||
{!noLabel && (
|
||||
<Stack className="gap-0.5">
|
||||
<div
|
||||
{...labelProps}
|
||||
className={twMerge(
|
||||
"select-none whitespace-normal md:whitespace-nowrap",
|
||||
labelProps?.className
|
||||
)}
|
||||
>
|
||||
{label || finalTitle}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
{info && (
|
||||
<Row className="gap-1" title={info.toString()}>
|
||||
|
@ -12,12 +12,23 @@ import Row from "../layout/Row";
|
||||
import Input from "./Input";
|
||||
import Loading from "../elements/Loading";
|
||||
|
||||
type FileInputUtils = {
|
||||
clearFileInput?: () => void;
|
||||
};
|
||||
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
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;
|
||||
onClear?: () => void;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
@ -74,6 +85,7 @@ export default function FileUpload({
|
||||
loading,
|
||||
multiple,
|
||||
onClear,
|
||||
changeHandler,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [file, setFile] = React.useState<
|
||||
@ -98,6 +110,19 @@ export default function FileUpload({
|
||||
}
|
||||
}, [existingFile]);
|
||||
|
||||
function clearFileInput() {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
const fileInputUtils: FileInputUtils = { clearFileInput };
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
@ -136,7 +161,12 @@ export default function FileUpload({
|
||||
(res) => {
|
||||
setFile(res);
|
||||
externalSetFile?.(res);
|
||||
onChangeHandler?.(res);
|
||||
onChangeHandler?.(
|
||||
res,
|
||||
inputRef,
|
||||
fileInputUtils
|
||||
);
|
||||
changeHandler?.(res, inputRef, fileInputUtils);
|
||||
fileInputProps?.onChange?.(e);
|
||||
}
|
||||
);
|
||||
@ -191,13 +221,7 @@ export default function FileUpload({
|
||||
"hover:bg-white dark:hover:bg-black"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
onClear?.();
|
||||
clearFileInput();
|
||||
}}
|
||||
title="Cancel File Upload Button"
|
||||
>
|
||||
@ -250,6 +274,7 @@ export default function FileUpload({
|
||||
setFile(undefined);
|
||||
externalSetFile?.(undefined);
|
||||
onChangeHandler?.(undefined);
|
||||
changeHandler?.(undefined);
|
||||
setFileUrl(undefined);
|
||||
}}
|
||||
title="Cancel File Button"
|
||||
@ -300,6 +325,7 @@ export default function FileUpload({
|
||||
setFile(res);
|
||||
externalSetFile?.(res);
|
||||
onChangeHandler?.(res);
|
||||
changeHandler?.(res);
|
||||
}
|
||||
);
|
||||
}}
|
||||
|
@ -75,6 +75,10 @@ export type InputProps<KeyType extends string> = DetailedHTMLProps<
|
||||
info?: string | ReactNode;
|
||||
ready?: boolean;
|
||||
validity?: TWUISelectValidityObject;
|
||||
clearInputProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
};
|
||||
|
||||
let refreshes = 0;
|
||||
@ -114,6 +118,7 @@ export default function Input<KeyType extends string>(
|
||||
info,
|
||||
changeHandler,
|
||||
validity: existingValidity,
|
||||
clearInputProps,
|
||||
...props
|
||||
} = inputProps;
|
||||
|
||||
@ -256,7 +261,7 @@ export default function Input<KeyType extends string>(
|
||||
}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full outline-none bg-transparent",
|
||||
"w-full outline-none bg-transparent grow",
|
||||
"twui-textarea",
|
||||
props.className
|
||||
)}
|
||||
@ -285,7 +290,7 @@ export default function Input<KeyType extends string>(
|
||||
"w-full outline-none bg-transparent border-none",
|
||||
"hover:border-none hover:outline-none focus:border-none focus:outline-none",
|
||||
"dark:bg-transparent dark:outline-none dark:border-none",
|
||||
"p-0",
|
||||
"p-0 grow",
|
||||
"twui-input",
|
||||
props.className
|
||||
)}
|
||||
@ -301,6 +306,7 @@ export default function Input<KeyType extends string>(
|
||||
onChange={handleValueChange}
|
||||
type={inputType}
|
||||
defaultValue={defaultInitialValue}
|
||||
autoComplete={autoComplete}
|
||||
value={props.value ? getFinalValue(props.value) : undefined}
|
||||
/>
|
||||
);
|
||||
@ -381,10 +387,12 @@ export default function Input<KeyType extends string>(
|
||||
{props.type == "search" || props.readOnly ? null : (
|
||||
<div
|
||||
title="Clear Input Field"
|
||||
{...clearInputProps}
|
||||
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",
|
||||
"twui-clear-input-field-button"
|
||||
"twui-clear-input-field-button",
|
||||
clearInputProps?.className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@ -397,9 +405,10 @@ export default function Input<KeyType extends string>(
|
||||
}
|
||||
|
||||
updateValue("");
|
||||
clearInputProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
<X className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
79
components/lib/form/Radios.tsx
Normal file
79
components/lib/form/Radios.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -47,17 +47,23 @@ export default function SearchSelect<
|
||||
const [currentOptions, setCurrentOptions] =
|
||||
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<
|
||||
TWUISelectOptionObject<KeyType, T>
|
||||
>({
|
||||
value: defaultOption.value,
|
||||
data: defaultOption.data,
|
||||
});
|
||||
TWUISelectOptionObject<KeyType, T> | undefined
|
||||
>(
|
||||
defaultOption
|
||||
? {
|
||||
value: defaultOption?.value,
|
||||
data: defaultOption?.data,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState<string>(
|
||||
defaultOption.value
|
||||
defaultOption?.value || ""
|
||||
);
|
||||
const [selectIndex, setSelectIndex] = React.useState<number | undefined>();
|
||||
|
||||
@ -91,11 +97,14 @@ export default function SearchSelect<
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatchState?.(value.data);
|
||||
setInputValue(value.value);
|
||||
if (value) {
|
||||
dispatchState?.(value.data);
|
||||
setInputValue(value.value);
|
||||
changeHandler?.(value.value);
|
||||
}
|
||||
|
||||
clearTimeout(focusTimeout);
|
||||
setOpen(false);
|
||||
changeHandler?.(value.value);
|
||||
setSelectIndex(undefined);
|
||||
}, [value]);
|
||||
|
||||
@ -246,7 +255,7 @@ export default function SearchSelect<
|
||||
targetWrapperProps={{ className: "w-full" }}
|
||||
contentWrapperProps={{ className: "w-full" }}
|
||||
className="w-full"
|
||||
externalOpen={open}
|
||||
externalOpen={currentOptions?.[0] && open}
|
||||
>
|
||||
<Paper
|
||||
className={twMerge(
|
||||
@ -255,37 +264,39 @@ export default function SearchSelect<
|
||||
componentRef={contentWrapperRef}
|
||||
>
|
||||
<Stack className="w-full items-start gap-0">
|
||||
{currentOptions.map((_o, index) => {
|
||||
const isTargetOption = index === selectIndex;
|
||||
const targetOptionClasses = twMerge(
|
||||
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
|
||||
"twui-select-target-option"
|
||||
);
|
||||
{currentOptions?.[0]
|
||||
? currentOptions.map((_o, index) => {
|
||||
const isTargetOption = index === selectIndex;
|
||||
const targetOptionClasses = twMerge(
|
||||
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
|
||||
"twui-select-target-option"
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Button
|
||||
title={_o.title || "Option"}
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(_o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={twMerge(
|
||||
"w-full text-foreground-light dark:text-foreground-dark",
|
||||
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
|
||||
isTargetOption
|
||||
? targetOptionClasses
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{_o.value}
|
||||
</Button>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<Button
|
||||
title={_o.title || "Option"}
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(_o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={twMerge(
|
||||
"w-full text-foreground-light dark:text-foreground-dark",
|
||||
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
|
||||
isTargetOption
|
||||
? targetOptionClasses
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{_o.value}
|
||||
</Button>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
|
@ -25,8 +25,8 @@ export type TWUISelectValidityObject = {
|
||||
};
|
||||
|
||||
export type TWUISelectOptionObject<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = any
|
||||
KeyType extends string = string,
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
> = {
|
||||
title?: string;
|
||||
value: KeyType;
|
||||
@ -36,7 +36,7 @@ export type TWUISelectOptionObject<
|
||||
|
||||
export type TWUISelectProps<
|
||||
KeyType extends string,
|
||||
T extends { [k: string]: any } = any
|
||||
T extends { [k: string]: any } = { [k: string]: any }
|
||||
> = DetailedHTMLProps<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
HTMLSelectElement
|
||||
@ -168,6 +168,7 @@ export default function Select<
|
||||
|
||||
<select
|
||||
id={selectID}
|
||||
aria-label={props["aria-label"] || props.title}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"w-full pl-3 py-2 rounded-default appearance-none pr-8",
|
||||
|
@ -73,7 +73,10 @@ export default function useWebSocket<
|
||||
* # Connect to Websocket
|
||||
*/
|
||||
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;
|
||||
|
||||
let ws = new WebSocket(wsURL);
|
||||
|
@ -18,9 +18,10 @@ export default function useWebSocketEventHandler<
|
||||
const dataEventListenerCallback = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
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 (message) setMessage(message);
|
||||
if (__msg && typeof __msg == "string") setMessage(__msg);
|
||||
};
|
||||
|
||||
const messageEventName: (typeof WebSocketEventNames)[number] =
|
||||
|
@ -1,5 +1,5 @@
|
||||
import _ from "lodash";
|
||||
import React, { DetailedHTMLProps, ImgHTMLAttributes } from "react";
|
||||
import React, { DetailedHTMLProps, ImgHTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUIImageProps = DetailedHTMLProps<
|
||||
@ -14,13 +14,23 @@ export type TWUIImageProps = DetailedHTMLProps<
|
||||
fallbackImageSrc?: string;
|
||||
srcLight?: string;
|
||||
srcDark?: string;
|
||||
imgErrSrc?: string;
|
||||
imgErrComp?: ReactNode;
|
||||
imgErrSrcLight?: string;
|
||||
imgErrSrcDark?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Image Component
|
||||
* @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 height = props.size || props.height;
|
||||
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
||||
@ -70,13 +80,16 @@ export default function Img({ ...props }: TWUIImageProps) {
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
src={
|
||||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
||||
}
|
||||
/>
|
||||
imgErrComp || (
|
||||
<img
|
||||
loading="lazy"
|
||||
{...interpolatedProps}
|
||||
src={
|
||||
imgErrSrc ||
|
||||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,7 @@ export function useMDXComponents(params?: Params) {
|
||||
if (React.isValidElement(children) && children.props) {
|
||||
return (
|
||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||
{/* @ts-ignore */}
|
||||
{children.props.children}
|
||||
</CodeBlock>
|
||||
);
|
||||
|
@ -58,6 +58,21 @@ export const work = {
|
||||
Devops: {
|
||||
href: "/work/devops",
|
||||
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",
|
||||
description:
|
||||
@ -81,7 +96,22 @@ export const work = {
|
||||
href: "/work/devops",
|
||||
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:
|
||||
"The easiest way to synchronize local and remote directories in real time",
|
||||
href: "https://git.tben.me/Moduletrace/turbo-sync",
|
||||
|
Loading…
Reference in New Issue
Block a user