Compare commits
15
Commits
c39dc35478
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634d72bbf5 | ||
|
|
57f1ecf5c3 | ||
|
|
d1ae498a2f | ||
|
|
3b9fa373bc | ||
|
|
8f5abed48d | ||
|
|
69d432b6af | ||
|
|
9505331165 | ||
|
|
d2ae053cda | ||
|
|
0b7c70058d | ||
|
|
979728e6c8 | ||
|
|
6d833c7d3b | ||
|
|
aceddf5146 | ||
|
|
1db7601c85 | ||
|
|
4cbe72fc8d | ||
|
|
d6ce943379 |
@@ -38,3 +38,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
dsql-schema-to-typedef.json
|
||||||
|
.data
|
||||||
@@ -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";
|
||||||
@@ -22,9 +22,11 @@ export default function ModalComponent({ open, setOpen, ...props }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"fixed z-[200] top-0 left-0 w-screen h-screen",
|
"fixed z-[200] top-0 left-0 w-screen h-screen",
|
||||||
"flex flex-col items-center justify-center",
|
"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(
|
||||||
@@ -38,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
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function PopoverComponent({
|
|||||||
<Paper
|
<Paper
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"max-w-[300px]",
|
"max-w-[300px] z-[250]",
|
||||||
"twui-popover-content",
|
"twui-popover-content",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ You need a couple of packages and settings to integrate this package
|
|||||||
|
|
||||||
### Packages
|
### Packages
|
||||||
|
|
||||||
- React
|
- React
|
||||||
- React Dom
|
- React Dom
|
||||||
- Tailwind CSS **version 4**
|
- Tailwind CSS **version 4**
|
||||||
|
|
||||||
### CSS Base
|
### CSS Base
|
||||||
|
|
||||||
This package contains a `base.css` file which has all the base css rules required to run. This css file must be imported in your base project, and it can be update in a separate `.css` file.
|
This package contains a `base.css` file which has all the base css rules required to run. This css file must be imported in your base project, and it can be update in a separate `.css` file.
|
||||||
|
|
||||||
|
### Install packages
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun add lucide-react tailwind-merge html-to-react gray-matter mdx typescript lodash react-code-blocks react-responsive-modal next-mdx-remote remark-gfm rehype-prism-plus openai
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun add -D @types/ace @types/react @types/react-dom tailwindcss @types/mdx @next/mdx
|
||||||
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -58,6 +60,9 @@
|
|||||||
--radius-default-xs: 1px;
|
--radius-default-xs: 1px;
|
||||||
--radius-default-lg: 7px;
|
--radius-default-lg: 7px;
|
||||||
--radius-default-xl: 10px;
|
--radius-default-xl: 10px;
|
||||||
|
|
||||||
|
--container-container: 1200px;
|
||||||
|
--container-modal: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
@@ -153,3 +158,16 @@ option {
|
|||||||
.normal-text {
|
.normal-text {
|
||||||
@apply text-foreground-light dark:text-foreground-dark;
|
@apply text-foreground-light dark:text-foreground-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin-left: 25px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export default function TWUIDocsLink({
|
|||||||
<TWUIDocsLink
|
<TWUIDocsLink
|
||||||
key={index}
|
key={index}
|
||||||
docLink={link}
|
docLink={link}
|
||||||
className="text-sm opacity-70"
|
className="opacity-70"
|
||||||
autoExpandAll={autoExpandAll}
|
autoExpandAll={autoExpandAll}
|
||||||
child
|
child
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -69,7 +75,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,
|
||||||
@@ -78,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({
|
||||||
@@ -97,7 +107,9 @@ export default function AceEditor({
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onChange(editor.getValue());
|
try {
|
||||||
|
onChange(editor.getValue());
|
||||||
|
} catch (error) {}
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -108,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;
|
||||||
@@ -123,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"
|
||||||
@@ -139,7 +162,7 @@ export default function AceEditor({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span className="text-sm m-0">
|
<span className="m-0">
|
||||||
Editor Error:{" "}
|
Editor Error:{" "}
|
||||||
<b className="text-red-600">{error.message}</b>
|
<b className="text-red-600">{error.message}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||||
import Border from "../../elements/Border";
|
import Border from "../../elements/Border";
|
||||||
|
import useTinyMCE from "./useTinyMCE";
|
||||||
|
|
||||||
export type TinyMCEEditorProps<KeyType extends string> = {
|
export type TinyMCEEditorProps<KeyType extends string> = {
|
||||||
tinyMCE?: TinyMCE | null;
|
|
||||||
options?: RawEditorOptions;
|
options?: RawEditorOptions;
|
||||||
editorRef?: React.MutableRefObject<Editor | null>;
|
editorRef?: React.MutableRefObject<Editor | null>;
|
||||||
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
|
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
|
||||||
@@ -24,19 +24,17 @@ export type TinyMCEEditorProps<KeyType extends string> = {
|
|||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
useParentCSS?: boolean;
|
useParentCSS?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
refreshDependencyArray?: any[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let interval: any;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Tiny MCE Editor Component
|
* # Tiny MCE Editor Component
|
||||||
* @className_wrapper twui-rte-wrapper
|
* @className_wrapper twui-rte-wrapper
|
||||||
*/
|
*/
|
||||||
export default function TinyMCEEditor<KeyType extends string>({
|
export default function TinyMCEEditor<KeyType extends string>({
|
||||||
options,
|
options,
|
||||||
editorRef,
|
editorRef: passedEditorRef,
|
||||||
setEditor,
|
setEditor: passedSetEditor,
|
||||||
tinyMCE,
|
|
||||||
wrapperProps,
|
wrapperProps,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
changeHandler,
|
changeHandler,
|
||||||
@@ -46,30 +44,52 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
showLabel,
|
showLabel,
|
||||||
useParentCSS,
|
useParentCSS,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
refreshDependencyArray,
|
||||||
}: TinyMCEEditorProps<KeyType>) {
|
}: TinyMCEEditorProps<KeyType>) {
|
||||||
|
const { tinyMCE } = useTinyMCE();
|
||||||
|
|
||||||
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const editorRef = passedEditorRef || React.useRef<Editor>(null);
|
||||||
|
|
||||||
|
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
|
||||||
|
|
||||||
const FINAL_HEIGHT = options?.height || 500;
|
const FINAL_HEIGHT = options?.height || 500;
|
||||||
const [themeReady, setThemeReady] = React.useState(false);
|
const [themeReady, setThemeReady] = React.useState(false);
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
const [darkMode, setDarkMode] = React.useState(false);
|
const [darkMode, setDarkMode] = React.useState(false);
|
||||||
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
|
const [editor, setEditor] = React.useState<Editor>();
|
||||||
|
|
||||||
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
|
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const htmlClassName = document.documentElement.className;
|
if (!tinyMCE) {
|
||||||
if (htmlClassName.match(/dark/i)) setDarkMode(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setThemeReady(true);
|
|
||||||
}, 200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!editorComponentRef.current || !themeReady) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tinyMCE?.init({
|
const htmlClassName = document.documentElement.className;
|
||||||
|
|
||||||
|
if (htmlClassName.match(/dark/i)) setDarkMode(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setThemeReady(true);
|
||||||
|
}, 200);
|
||||||
|
}, [tinyMCE]);
|
||||||
|
|
||||||
|
let valueTimeout: any;
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!editorComponentRef.current || !themeReady || !tinyMCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
|
||||||
|
"https://www.datasquirel.com/tinymce-public";
|
||||||
|
|
||||||
|
tinyMCE.init({
|
||||||
height: FINAL_HEIGHT,
|
height: FINAL_HEIGHT,
|
||||||
menubar: false,
|
menubar: false,
|
||||||
plugins:
|
plugins:
|
||||||
@@ -79,20 +99,33 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
content_style:
|
content_style:
|
||||||
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
|
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
|
||||||
init_instance_callback: (editor) => {
|
init_instance_callback: (editor) => {
|
||||||
setEditor?.(editor as any);
|
setEditor(editor as any);
|
||||||
if (editorRef) editorRef.current = editor as any;
|
if (editorRef) {
|
||||||
|
editorRef.current = editor;
|
||||||
|
passedSetEditor?.(editor);
|
||||||
|
}
|
||||||
if (defaultValue) editor.setContent(defaultValue);
|
if (defaultValue) editor.setContent(defaultValue);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
|
||||||
|
// editor.on("change", (e) => {
|
||||||
|
// changeHandler?.(editor.getContent());
|
||||||
|
// });
|
||||||
|
|
||||||
editor.on("input", (e) => {
|
editor.on("input", (e) => {
|
||||||
changeHandler?.(editor.getContent());
|
if (changeHandler) {
|
||||||
|
window.clearTimeout(valueTimeout);
|
||||||
|
|
||||||
|
valueTimeout = setTimeout(() => {
|
||||||
|
changeHandler(editor.getContent());
|
||||||
|
}, EDITOR_VALUE_CHANGE_TIMEOUT);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (useParentCSS) {
|
if (useParentCSS) {
|
||||||
useParentStyles(editor);
|
useParentStyles(editor);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
base_url: "https://datasquirel.com/tinymce-public",
|
base_url: baseUrl,
|
||||||
body_class: "twui-tinymce",
|
body_class: "twui-tinymce",
|
||||||
placeholder,
|
placeholder,
|
||||||
relative_urls: true,
|
relative_urls: true,
|
||||||
@@ -106,9 +139,22 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return function () {
|
return function () {
|
||||||
tinyMCE?.remove();
|
if (!ready) return;
|
||||||
|
|
||||||
|
const instance = editorComponentRef.current
|
||||||
|
? tinyMCE?.get(editorComponentRef.current?.id)
|
||||||
|
: undefined;
|
||||||
|
instance?.remove();
|
||||||
};
|
};
|
||||||
}, [tinyMCE, themeReady]);
|
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const instance = editorRef.current;
|
||||||
|
|
||||||
|
if (instance) {
|
||||||
|
instance.setContent(defaultValue || "");
|
||||||
|
}
|
||||||
|
}, [defaultValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -116,17 +162,20 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative w-full [&_.tox-tinymce]:!border-none",
|
"relative w-full [&_.tox-tinymce]:!border-none",
|
||||||
"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
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
|
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
|
||||||
"bg-background-light dark:bg-background-dark text-gray-500",
|
"bg-background-light dark:bg-background-dark text-gray-500",
|
||||||
"dark:text-white/80 rounded"
|
"dark:text-white/80 rounded",
|
||||||
)}
|
)}
|
||||||
htmlFor={name || "twui-tinymce"}
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</label>
|
</label>
|
||||||
@@ -135,7 +184,7 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
{...borderProps}
|
{...borderProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"dark:border-white/30 p-0 pt-2",
|
"dark:border-white/30 p-0 pt-2",
|
||||||
borderProps?.className
|
borderProps?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -148,9 +197,9 @@ export default function TinyMCEEditor<KeyType extends string>({
|
|||||||
}}
|
}}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
|
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
|
||||||
"twui-rte-wrapper"
|
"twui-rte-wrapper",
|
||||||
)}
|
)}
|
||||||
id={name || "twui-tinymce"}
|
id={id}
|
||||||
></div>
|
></div>
|
||||||
</Border>
|
</Border>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,31 +4,49 @@ import { TinyMCE } from "./tinymce";
|
|||||||
let interval: any;
|
let interval: any;
|
||||||
|
|
||||||
export default function useTinyMCE() {
|
export default function useTinyMCE() {
|
||||||
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE | null>(null);
|
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE>();
|
||||||
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
|
const [scriptLoaded, setScriptLoaded] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// @ts-ignore
|
if (refresh >= 5) return;
|
||||||
if (window.tinymce) {
|
|
||||||
console.log("Tinymce already exists");
|
const clientWindow = window as Window & { tinymce?: TinyMCE };
|
||||||
// @ts-ignore
|
|
||||||
setTinyMCE(window.tinymce);
|
if (clientWindow.tinymce) {
|
||||||
|
setScriptLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = "https://datasquirel.com/tinymce-public/tinymce.min.js";
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_TINYMCE_BASE_URL ||
|
||||||
|
"https://www.datasquirel.com/tinymce-public";
|
||||||
|
|
||||||
|
script.src = `${baseUrl}/tinymce.min.js`;
|
||||||
script.async = true;
|
script.async = true;
|
||||||
|
|
||||||
document.head.appendChild(script);
|
|
||||||
|
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// @ts-ignore
|
setScriptLoaded(true);
|
||||||
if (window.tinymce) {
|
|
||||||
// @ts-ignore
|
|
||||||
setTinyMCE(window.tinymce);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!scriptLoaded) return;
|
||||||
|
|
||||||
|
const clientWindow = window as Window & { tinymce?: TinyMCE };
|
||||||
|
|
||||||
|
let tinyMCE = clientWindow.tinymce;
|
||||||
|
|
||||||
|
if (tinyMCE) {
|
||||||
|
setTinyMCE(tinyMCE);
|
||||||
|
} else {
|
||||||
|
setRefresh((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [scriptLoaded]);
|
||||||
|
|
||||||
return { tinyMCE };
|
return { tinyMCE };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
import { DetailedHTMLProps, HTMLAttributes, RefObject } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
|
export type TWUI_BORDER_PROPS = DetailedHTMLProps<
|
||||||
@@ -6,13 +6,18 @@ export type TWUI_BORDER_PROPS = DetailedHTMLProps<
|
|||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
|
spacing?: "normal" | "loose" | "tight" | "wide" | "tightest";
|
||||||
|
componentRef?: RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Toggle Component
|
* # Toggle Component
|
||||||
* @className_wrapper twui-border
|
* @className_wrapper twui-border
|
||||||
*/
|
*/
|
||||||
export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
|
export default function Border({
|
||||||
|
spacing,
|
||||||
|
componentRef,
|
||||||
|
...props
|
||||||
|
}: TWUI_BORDER_PROPS) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
@@ -29,6 +34,7 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
|
|||||||
"twui-border",
|
"twui-border",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
ref={componentRef}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Props = {
|
|||||||
* @className `twui-breadcrumb-link`
|
* @className `twui-breadcrumb-link`
|
||||||
* @className `twui-current-breadcrumb-wrapper`
|
* @className `twui-current-breadcrumb-wrapper`
|
||||||
* @className `twui-breadcrumbs-divider`
|
* @className `twui-breadcrumbs-divider`
|
||||||
|
* @className `twui-breadcrumbs-back-button`
|
||||||
*/
|
*/
|
||||||
export default function Breadcrumbs({
|
export default function Breadcrumbs({
|
||||||
excludeRegexMatch,
|
excludeRegexMatch,
|
||||||
@@ -92,6 +93,7 @@ export default function Breadcrumbs({
|
|||||||
{...backButtonProps}
|
{...backButtonProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"p-1 -my-2 -mx-2",
|
"p-1 -my-2 -mx-2",
|
||||||
|
"twui-breadcrumbs-back-button",
|
||||||
backButtonProps?.className
|
backButtonProps?.className
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -99,9 +101,8 @@ export default function Breadcrumbs({
|
|||||||
backButtonProps?.onClick?.(e);
|
backButtonProps?.onClick?.(e);
|
||||||
}}
|
}}
|
||||||
title="Breadcrumbs Back Button"
|
title="Breadcrumbs Back Button"
|
||||||
>
|
beforeIcon={<ChevronLeft size={20} />}
|
||||||
<ChevronLeft size={20} />
|
/>
|
||||||
</Button>
|
|
||||||
{divider || (
|
{divider || (
|
||||||
<Divider
|
<Divider
|
||||||
vertical
|
vertical
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export const TWUIDropdownContentPositions = [
|
|||||||
"left",
|
"left",
|
||||||
"bottom-left",
|
"bottom-left",
|
||||||
"top-left",
|
"top-left",
|
||||||
|
"top",
|
||||||
|
"bottom",
|
||||||
"right",
|
"right",
|
||||||
"bottom-right",
|
"bottom-right",
|
||||||
"top-right",
|
"top-right",
|
||||||
@@ -160,6 +162,8 @@ export default function Dropdown({
|
|||||||
? "right-0 top-[100%]"
|
? "right-0 top-[100%]"
|
||||||
: position == "center"
|
: position == "center"
|
||||||
? "left-[50%] -translate-x-[50%] top-[100%]"
|
? "left-[50%] -translate-x-[50%] top-[100%]"
|
||||||
|
: position == "top"
|
||||||
|
? "left-[50%] -translate-x-[50%] bottom-[100%]"
|
||||||
: "top-[100%]",
|
: "top-[100%]",
|
||||||
above ? "-translate-y-[120%]" : "",
|
above ? "-translate-y-[120%]" : "",
|
||||||
open ? "flex" : "hidden",
|
open ? "flex" : "hidden",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -130,7 +131,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) => {
|
||||||
@@ -143,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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -2,31 +2,44 @@ import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
|||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Center from "../layout/Center";
|
import Center from "../layout/Center";
|
||||||
import Loading from "./Loading";
|
import Loading from "./Loading";
|
||||||
|
import Row from "../layout/Row";
|
||||||
|
import Span from "../layout/Span";
|
||||||
|
|
||||||
type Props = DetailedHTMLProps<
|
type Props = DetailedHTMLProps<
|
||||||
HTMLAttributes<HTMLDivElement>,
|
HTMLAttributes<HTMLDivElement>,
|
||||||
HTMLDivElement
|
HTMLDivElement
|
||||||
> & {
|
> & {
|
||||||
loadingProps?: ComponentProps<typeof Loading>;
|
loadingProps?: ComponentProps<typeof Loading>;
|
||||||
|
label?: string;
|
||||||
|
fixed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Loading Overlay Component
|
* # Loading Overlay Component
|
||||||
* @className_wrapper twui-loading-overlay
|
* @className_wrapper twui-loading-overlay
|
||||||
*/
|
*/
|
||||||
export default function LoadingOverlay({ loadingProps, ...props }: Props) {
|
export default function LoadingOverlay({
|
||||||
|
loadingProps,
|
||||||
|
label,
|
||||||
|
fixed,
|
||||||
|
...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>
|
||||||
<Loading {...loadingProps} />
|
<Row>
|
||||||
|
<Loading {...loadingProps} />
|
||||||
|
{label && <Span>{label}</Span>}
|
||||||
|
</Row>
|
||||||
</Center>
|
</Center>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type TWUI_MODAL_PROPS = DetailedHTMLProps<
|
|||||||
trigger?: (typeof TWUIPopoverTriggers)[number];
|
trigger?: (typeof TWUIPopoverTriggers)[number];
|
||||||
debounce?: number;
|
debounce?: number;
|
||||||
onClose?: () => any;
|
onClose?: () => any;
|
||||||
|
hoverOpen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +56,7 @@ export default function Modal(props: TWUI_MODAL_PROPS) {
|
|||||||
trigger = "hover",
|
trigger = "hover",
|
||||||
debounce = 500,
|
debounce = 500,
|
||||||
onClose,
|
onClose,
|
||||||
|
hoverOpen,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
@@ -65,6 +67,9 @@ export default function Modal(props: TWUI_MODAL_PROPS) {
|
|||||||
const modalRoot = document.getElementById(IDName);
|
const modalRoot = document.getElementById(IDName);
|
||||||
|
|
||||||
if (modalRoot) {
|
if (modalRoot) {
|
||||||
|
if (isPopover) {
|
||||||
|
modalRoot.style.zIndex = "1000";
|
||||||
|
}
|
||||||
setReady(true);
|
setReady(true);
|
||||||
} else {
|
} else {
|
||||||
const newModalRootEl = document.createElement("div");
|
const newModalRootEl = document.createElement("div");
|
||||||
@@ -141,15 +146,19 @@ 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"
|
isPopover && (trigger === "hover" || hoverOpen)
|
||||||
? popoverEnterFn
|
? popoverEnterFn
|
||||||
: targetWrapperProps?.onMouseEnter
|
: targetWrapperProps?.onMouseEnter
|
||||||
}
|
}
|
||||||
onMouseLeave={
|
onMouseLeave={
|
||||||
isPopover && trigger === "hover"
|
isPopover && (trigger === "hover" || hoverOpen)
|
||||||
? popoverLeaveFn
|
? popoverLeaveFn
|
||||||
: targetWrapperProps?.onMouseLeave
|
: targetWrapperProps?.onMouseLeave
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { serialize } from "next-mdx-remote/serialize";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypePrismPlus from "rehype-prism-plus";
|
||||||
|
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||||
|
import { useMDXComponents } from "../mdx/mdx-components";
|
||||||
|
|
||||||
|
export const TWUIPrismLanguages = ["shell", "javascript"] as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: string;
|
||||||
|
refresh?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # CodeBlock
|
||||||
|
*
|
||||||
|
* @className `twui-remote-code-block-wrapper`
|
||||||
|
*/
|
||||||
|
export default function RemoteCodeBlock({ content, refresh }: Props) {
|
||||||
|
const [mdxSource, setMdxSource] =
|
||||||
|
React.useState<MDXRemoteSerializeResult<any>>();
|
||||||
|
|
||||||
|
const { components } = useMDXComponents();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
serialize(content, {
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [remarkGfm],
|
||||||
|
rehypePlugins: [rehypePrismPlus],
|
||||||
|
},
|
||||||
|
}).then((mdxSrc) => {
|
||||||
|
setMdxSource(mdxSrc);
|
||||||
|
});
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
if (!mdxSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MDXRemote
|
||||||
|
{...mdxSource}
|
||||||
|
components={{
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
|||||||
>;
|
>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
componentRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,9 +38,12 @@ export default function Search<KeyType extends string>({
|
|||||||
buttonProps,
|
buttonProps,
|
||||||
loading,
|
loading,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
componentRef,
|
||||||
...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);
|
||||||
@@ -50,19 +54,19 @@ export default function Search<KeyType extends string>({
|
|||||||
}, delay);
|
}, delay);
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
// React.useEffect(() => {
|
||||||
if (props.autoFocus) {
|
// if (props.autoFocus) {
|
||||||
inputRef.current?.focus();
|
// inputRef.current?.focus();
|
||||||
}
|
// }
|
||||||
}, []);
|
// }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"relative xl:flex-nowrap items-stretch gap-0",
|
"relative xl:flex-nowrap items-stretch gap-0 flex-nowrap",
|
||||||
"twui-search-wrapper",
|
"twui-search-wrapper",
|
||||||
props?.className
|
props?.className
|
||||||
)}
|
)}
|
||||||
@@ -74,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}
|
||||||
/>
|
/>
|
||||||
@@ -89,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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function Table({ data }: Props) {
|
|||||||
<th
|
<th
|
||||||
key={header}
|
key={header}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"px-3 py-2 text-left text-sm opacity-50",
|
"px-3 py-2 text-left opacity-50",
|
||||||
"font-semibold"
|
"font-semibold"
|
||||||
)}
|
)}
|
||||||
title={header}
|
title={header}
|
||||||
@@ -58,7 +58,7 @@ export default function Table({ data }: Props) {
|
|||||||
<td
|
<td
|
||||||
key={`${header}-${index}`}
|
key={`${header}-${index}`}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"px-3 py-2 whitespace-nowrap text-sm text-foreground-light",
|
"px-3 py-2 whitespace-nowrap text-foreground-light",
|
||||||
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
|
"dark:text-foreground-dark max-w-[200px] overflow-hidden",
|
||||||
"overflow-ellipsis"
|
"overflow-ellipsis"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import twuiSlugify from "../utils/slugify";
|
|||||||
export type TWUITabsObject = {
|
export type TWUITabsObject = {
|
||||||
title: string;
|
title: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
content: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
defaultActive?: boolean;
|
defaultActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ 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;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
hrefUpdate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +37,8 @@ export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
|||||||
* @className twui-tab-buttons
|
* @className twui-tab-buttons
|
||||||
* @className twui-tab-button-active
|
* @className twui-tab-button-active
|
||||||
* @className twui-tab-buttons-wrapper
|
* @className twui-tab-buttons-wrapper
|
||||||
|
* @className twui-tab-buttons-container
|
||||||
|
* @className twui-tabs-border
|
||||||
*/
|
*/
|
||||||
export default function Tabs({
|
export default function Tabs({
|
||||||
tabsContentArray,
|
tabsContentArray,
|
||||||
@@ -43,6 +48,9 @@ export default function Tabs({
|
|||||||
debounce = 100,
|
debounce = 100,
|
||||||
switchComponent,
|
switchComponent,
|
||||||
setActiveValue: existingSetActiveValue,
|
setActiveValue: existingSetActiveValue,
|
||||||
|
changeHandler,
|
||||||
|
defaultValue,
|
||||||
|
hrefUpdate,
|
||||||
...props
|
...props
|
||||||
}: TWUI_TOGGLE_PROPS) {
|
}: TWUI_TOGGLE_PROPS) {
|
||||||
const finalTabsContentArray = tabsContentArray
|
const finalTabsContentArray = tabsContentArray
|
||||||
@@ -50,28 +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
|
||||||
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
? defaultValue
|
||||||
: values[0] || undefined
|
: defaultActiveObj
|
||||||
|
? defaultActiveObj?.value || twuiSlugify(defaultActiveObj.title)
|
||||||
|
: 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) {
|
||||||
|
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}
|
||||||
@@ -82,24 +122,25 @@ 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
|
||||||
className="p-0 w-full overflow-hidden"
|
className="p-0 w-full overflow-hidden twui-tabs-border"
|
||||||
{...tabsBorderProps}
|
{...tabsBorderProps}
|
||||||
>
|
>
|
||||||
<Row
|
<Row
|
||||||
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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{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;
|
||||||
@@ -112,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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -70,15 +74,15 @@ export default function Toast({
|
|||||||
<Card
|
<Card
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"absolute 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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>>;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPromptBlock({
|
||||||
|
model,
|
||||||
|
promptFn,
|
||||||
|
history = [],
|
||||||
|
loading = false,
|
||||||
|
mdRes = "",
|
||||||
|
setMdRes,
|
||||||
|
placeholder,
|
||||||
|
}: Props) {
|
||||||
|
const [prompt, setPrompt] = React.useState("");
|
||||||
|
const currentPromptRef = React.useRef("");
|
||||||
|
const promptInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="">
|
||||||
|
<Stack className="w-full">
|
||||||
|
{currentPromptRef.current && (
|
||||||
|
<Row className="w-full justify-end">
|
||||||
|
<Card className="py-1.5 px-2.5 text-xs">
|
||||||
|
<Row>
|
||||||
|
<Span>{currentPromptRef.current}</Span>
|
||||||
|
<MessageCircleMore
|
||||||
|
size={15}
|
||||||
|
opacity={0.5}
|
||||||
|
className="-mt-px"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<AIPromptPreview
|
||||||
|
setStreamRes={setMdRes}
|
||||||
|
streamRes={mdRes}
|
||||||
|
history={history}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<Stack className="w-full relative">
|
||||||
|
{loading && <LoadingOverlay />}
|
||||||
|
<Textarea
|
||||||
|
placeholder={
|
||||||
|
placeholder ||
|
||||||
|
(model ? `Prompt ${model}` : "Prompt AI")
|
||||||
|
}
|
||||||
|
wrapperProps={{ className: "outline-none" }}
|
||||||
|
wrapperWrapperProps={{ className: "w-full" }}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPrompt(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key == "Enter" && !e.ctrlKey && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
currentPromptRef.current = prompt;
|
||||||
|
setTimeout(() => {
|
||||||
|
setPrompt("");
|
||||||
|
if (promptInputRef.current) {
|
||||||
|
promptInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
promptFn(prompt);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
componentRef={promptInputRef}
|
||||||
|
// autoFocus
|
||||||
|
/>
|
||||||
|
<AIPromptActionSection
|
||||||
|
loading={loading}
|
||||||
|
promptFn={promptFn}
|
||||||
|
setStreamRes={setMdRes}
|
||||||
|
streamRes={mdRes}
|
||||||
|
history={history}
|
||||||
|
prompt={prompt}
|
||||||
|
setPrompt={setPrompt}
|
||||||
|
currentPromptRef={currentPromptRef}
|
||||||
|
promptInputRef={promptInputRef as any}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-8"
|
||||||
|
>
|
||||||
|
<Stack className="gap-10 w-full">
|
||||||
|
<Stack className="gap-1">
|
||||||
|
<H2 className="!text-xl m-0">Chat History</H2>
|
||||||
|
<Span className="text-xs">
|
||||||
|
AI chat history for this session.
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{history.map((hst, index) => {
|
||||||
|
if (hst.role == "user") {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Divider from "../../layout/Divider";
|
||||||
|
import Stack from "../../layout/Stack";
|
||||||
|
import React from "react";
|
||||||
|
import MarkdownEditorPreviewComponent from "../../mdx/markdown/MarkdownEditorPreviewComponent";
|
||||||
|
import { ChatCompletionMessageParam } from "openai/resources/index";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
streamRes: string;
|
||||||
|
setStreamRes: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
history: ChatCompletionMessageParam[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPromptPreview({
|
||||||
|
setStreamRes,
|
||||||
|
streamRes,
|
||||||
|
history,
|
||||||
|
loading,
|
||||||
|
}: Props) {
|
||||||
|
const responseContentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isContentInterrupted = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isContentInterrupted.current) return;
|
||||||
|
|
||||||
|
if (responseContentRef.current) {
|
||||||
|
responseContentRef.current.scrollTop =
|
||||||
|
responseContentRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [streamRes]);
|
||||||
|
|
||||||
|
if (loading || !streamRes?.match(/./)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full">
|
||||||
|
<MarkdownEditorPreviewComponent
|
||||||
|
value={streamRes}
|
||||||
|
maxHeight="40vh"
|
||||||
|
wrapperProps={{
|
||||||
|
className: "border-none p-0 ai-response-content",
|
||||||
|
componentRef: responseContentRef as any,
|
||||||
|
onMouseEnter: () => {
|
||||||
|
isContentInterrupted.current = true;
|
||||||
|
},
|
||||||
|
onMouseLeave: () => {
|
||||||
|
isContentInterrupted.current = false;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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.HTMLAttributes<HTMLDivElement>,
|
React.DetailedHTMLProps<
|
||||||
HTMLDivElement
|
React.HTMLAttributes<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,16 +59,18 @@ export default function Checkbox({
|
|||||||
changeHandler,
|
changeHandler,
|
||||||
info,
|
info,
|
||||||
wrapperWrapperProps,
|
wrapperWrapperProps,
|
||||||
|
noLabel,
|
||||||
|
title,
|
||||||
...props
|
...props
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
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 = props.title
|
const finalTitle = title
|
||||||
? props.title
|
? title
|
||||||
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
: `Checkbox-${Math.round(Math.random() * 100000)}`;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -86,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);
|
||||||
@@ -101,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",
|
||||||
@@ -112,21 +119,23 @@ export default function Checkbox({
|
|||||||
>
|
>
|
||||||
{checked && <CheckMarkSVG />}
|
{checked && <CheckMarkSVG />}
|
||||||
</div>
|
</div>
|
||||||
<Stack className="gap-0.5">
|
{!noLabel && (
|
||||||
<div
|
<Stack className="gap-0.5">
|
||||||
{...labelProps}
|
<div
|
||||||
className={twMerge(
|
{...labelProps}
|
||||||
"select-none whitespace-normal md:whitespace-nowrap",
|
className={twMerge(
|
||||||
labelProps?.className
|
"select-none whitespace-normal md:whitespace-nowrap",
|
||||||
)}
|
labelProps?.className,
|
||||||
>
|
)}
|
||||||
{label || finalTitle}
|
>
|
||||||
</div>
|
{label || finalTitle}
|
||||||
</Stack>
|
</div>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -11,13 +11,25 @@ import fileInputToBase64 from "../utils/form/fileInputToBase64";
|
|||||||
import Row from "../layout/Row";
|
import Row from "../layout/Row";
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import Loading from "../elements/Loading";
|
import Loading from "../elements/Loading";
|
||||||
|
import Tag from "../elements/Tag";
|
||||||
|
|
||||||
|
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<
|
||||||
@@ -48,6 +60,7 @@ type ImageUploadProps = DetailedHTMLProps<
|
|||||||
existingFile?: FileInputToBase64FunctionReturn;
|
existingFile?: FileInputToBase64FunctionReturn;
|
||||||
existingFileUrl?: string;
|
existingFileUrl?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
externalSetFileURL?: React.Dispatch<string | undefined>;
|
||||||
labelSpanProps?: ComponentProps<typeof Span>;
|
labelSpanProps?: ComponentProps<typeof Span>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
@@ -74,17 +87,20 @@ export default function FileUpload({
|
|||||||
loading,
|
loading,
|
||||||
multiple,
|
multiple,
|
||||||
onClear,
|
onClear,
|
||||||
|
changeHandler,
|
||||||
|
externalSetFileURL,
|
||||||
...props
|
...props
|
||||||
}: ImageUploadProps) {
|
}: ImageUploadProps) {
|
||||||
const [file, setFile] = React.useState<
|
const [file, setFile] = React.useState<
|
||||||
FileInputToBase64FunctionReturn | undefined
|
FileInputToBase64FunctionReturn | undefined
|
||||||
>(existingFile);
|
>(existingFile);
|
||||||
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
|
const [fileUrl, setFileUrl] = React.useState<string | undefined>(
|
||||||
existingFileUrl
|
existingFileUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
|
const [fileDraggedOver, setFileDraggedOver] = React.useState(false);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const tempFileURLRef = React.useRef<string>("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (existingFileUrl) {
|
if (existingFileUrl) {
|
||||||
@@ -98,6 +114,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,9 +165,14 @@ 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);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -157,7 +191,7 @@ export default function FileUpload({
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-full relative h-full items-center justify-center overflow-hidden",
|
"w-full relative h-full items-center justify-center overflow-hidden",
|
||||||
"pb-10",
|
"pb-10",
|
||||||
previewImageWrapperProps?.className
|
previewImageWrapperProps?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -188,16 +222,10 @@ export default function FileUpload({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
|
"absolute p-2 top-2 right-2 z-20 bg-background-light dark:bg-background-dark",
|
||||||
"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"
|
||||||
>
|
>
|
||||||
@@ -220,36 +248,48 @@ export default function FileUpload({
|
|||||||
className="w-full relative h-full items-center justify-center overflow-hidden"
|
className="w-full relative h-full items-center justify-center overflow-hidden"
|
||||||
{...previewImageWrapperProps}
|
{...previewImageWrapperProps}
|
||||||
>
|
>
|
||||||
{disablePreview ? (
|
<Stack className="w-full">
|
||||||
<Span className="opacity-50" size="small">
|
{disablePreview ? (
|
||||||
Image Uploaded!
|
<Span className="opacity-50" size="small">
|
||||||
</Span>
|
Image Uploaded!
|
||||||
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
</Span>
|
||||||
<Row>
|
) : fileUrl.match(/\.pdf$|\.txt$/) ? (
|
||||||
<FileArchive size={36} strokeWidth={1} />
|
<Row>
|
||||||
<Stack className="gap-0">
|
<FileArchive size={36} strokeWidth={1} />
|
||||||
<Span size="smaller" className="opacity-70">
|
<Stack className="gap-0">
|
||||||
{fileUrl}
|
<Span size="smaller" className="opacity-70">
|
||||||
</Span>
|
{fileUrl}
|
||||||
</Stack>
|
</Span>
|
||||||
</Row>
|
</Stack>
|
||||||
) : (
|
</Row>
|
||||||
<img
|
) : (
|
||||||
src={fileUrl}
|
<img
|
||||||
className="w-full object-contain overflow-hidden"
|
src={fileUrl}
|
||||||
{...previewImageProps}
|
className="w-full object-contain overflow-hidden"
|
||||||
/>
|
{...previewImageProps}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tag
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
className="w-full py-2 text-sm"
|
||||||
|
>
|
||||||
|
{fileUrl}
|
||||||
|
</Tag>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
|
"absolute p-2 top-2 right-2 z-20 bg-white dark:bg-black",
|
||||||
"hover:bg-white dark:hover:bg-black"
|
"hover:bg-white dark:hover:bg-black",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
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"
|
||||||
@@ -263,7 +303,7 @@ export default function FileUpload({
|
|||||||
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
|
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
|
||||||
"border-dashed border-2",
|
"border-dashed border-2",
|
||||||
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
|
fileDraggedOver ? "bg-slate-100 dark:bg-white/10" : "",
|
||||||
placeHolderWrapper?.className
|
placeHolderWrapper?.className,
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
@@ -300,14 +340,15 @@ export default function FileUpload({
|
|||||||
setFile(res);
|
setFile(res);
|
||||||
externalSetFile?.(res);
|
externalSetFile?.(res);
|
||||||
onChangeHandler?.(res);
|
onChangeHandler?.(res);
|
||||||
}
|
changeHandler?.(res);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
{...placeHolderWrapper}
|
{...placeHolderWrapper}
|
||||||
>
|
>
|
||||||
<Center
|
<Center
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
fileDraggedOver ? "pointer-events-none" : ""
|
fileDraggedOver ? "pointer-events-none" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Stack className="items-center gap-2">
|
<Stack className="items-center gap-2">
|
||||||
@@ -319,6 +360,59 @@ export default function FileUpload({
|
|||||||
>
|
>
|
||||||
{label || "Click to Upload File"}
|
{label || "Click to Upload File"}
|
||||||
</Span>
|
</Span>
|
||||||
|
{externalSetFileURL ? (
|
||||||
|
<Row
|
||||||
|
className="flex-nowrap gap-0 items-stretch"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Add Media URL"
|
||||||
|
className="text-sm"
|
||||||
|
wrapperProps={{ className: "h-full" }}
|
||||||
|
wrapperWrapperProps={{
|
||||||
|
className: "h-full",
|
||||||
|
}}
|
||||||
|
changeHandler={(value) => {
|
||||||
|
tempFileURLRef.current = value;
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key == "Enter") {
|
||||||
|
if (tempFileURLRef.current) {
|
||||||
|
setFileUrl(
|
||||||
|
tempFileURLRef.current,
|
||||||
|
);
|
||||||
|
externalSetFileURL(
|
||||||
|
tempFileURLRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Add Media URL"
|
||||||
|
variant="outlined"
|
||||||
|
className="py-0 px-3"
|
||||||
|
color="gray"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (tempFileURLRef.current) {
|
||||||
|
setFileUrl(
|
||||||
|
tempFileURLRef.current,
|
||||||
|
);
|
||||||
|
externalSetFileURL(
|
||||||
|
tempFileURLRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">+</span>
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import { Info, Minus, Plus } from "lucide-react";
|
import { Info, Minus, Plus } from "lucide-react";
|
||||||
import twuiNumberfy from "../../utils/numberfy";
|
import twuiNumberfy from "../../utils/numberfy";
|
||||||
@@ -7,10 +7,10 @@ import { InputProps } from ".";
|
|||||||
let pressInterval: any;
|
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" | "decimal"> & {
|
||||||
updateValue: (v: string) => void;
|
value: string;
|
||||||
getNormalizedValue: (v: string) => void;
|
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||||
buttonDownRef: React.MutableRefObject<boolean>;
|
buttonDownRef: React.RefObject<boolean>;
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,21 +18,50 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step"> & {
|
|||||||
* # Input Number Text Buttons
|
* # Input Number Text Buttons
|
||||||
*/
|
*/
|
||||||
export default function NumberInputButtons({
|
export default function NumberInputButtons({
|
||||||
getNormalizedValue,
|
value,
|
||||||
updateValue,
|
setValue,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
buttonDownRef,
|
buttonDownRef,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
decimal,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const PRESS_TRIGGER_TIMEOUT = 200;
|
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||||
const DEFAULT_STEP = 1;
|
const DEFAULT_STEP = 1;
|
||||||
|
|
||||||
|
const [buttonDown, setButtonDown] = useState(false);
|
||||||
|
|
||||||
|
// function getNormalizedValue(value: string) {
|
||||||
|
// if (numberText) {
|
||||||
|
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
||||||
|
// return getFinalValue(props.max);
|
||||||
|
|
||||||
|
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
||||||
|
// return getFinalValue(props.min);
|
||||||
|
|
||||||
|
// return getFinalValue(value);
|
||||||
|
// } else {
|
||||||
|
// return value;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buttonDownRef.current = buttonDown;
|
||||||
|
if (buttonDown) {
|
||||||
|
setValue(inputRef.current?.value || "");
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
setValue(inputRef.current?.value || "");
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [buttonDown]);
|
||||||
|
|
||||||
function incrementDownPress() {
|
function incrementDownPress() {
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
|
setButtonDown(true);
|
||||||
|
|
||||||
pressTimeout = setTimeout(() => {
|
pressTimeout = setTimeout(() => {
|
||||||
buttonDownRef.current = true;
|
|
||||||
pressInterval = setInterval(() => {
|
pressInterval = setInterval(() => {
|
||||||
increment();
|
increment();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -40,14 +69,15 @@ export default function NumberInputButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function incrementDownCancel() {
|
function incrementDownCancel() {
|
||||||
buttonDownRef.current = false;
|
setButtonDown(false);
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
window.clearInterval(pressInterval);
|
window.clearInterval(pressInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrementDownPress() {
|
function decrementDownPress() {
|
||||||
|
setButtonDown(true);
|
||||||
|
|
||||||
pressTimeout = setTimeout(() => {
|
pressTimeout = setTimeout(() => {
|
||||||
buttonDownRef.current = true;
|
|
||||||
pressInterval = setInterval(() => {
|
pressInterval = setInterval(() => {
|
||||||
decrement();
|
decrement();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -55,37 +85,51 @@ export default function NumberInputButtons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decrementDownCancel() {
|
function decrementDownCancel() {
|
||||||
buttonDownRef.current = false;
|
setButtonDown(false);
|
||||||
window.clearTimeout(pressTimeout);
|
window.clearTimeout(pressTimeout);
|
||||||
window.clearInterval(pressInterval);
|
window.clearInterval(pressInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function increment() {
|
function increment() {
|
||||||
const existingValue = inputRef.current?.value;
|
if (!inputRef.current) return;
|
||||||
const existingNumberValue = twuiNumberfy(existingValue);
|
|
||||||
|
|
||||||
if (max && existingNumberValue >= twuiNumberfy(max)) {
|
const existingValue = inputRef.current.value;
|
||||||
return updateValue(String(max));
|
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||||
} else if (min && existingNumberValue < twuiNumberfy(min)) {
|
|
||||||
return updateValue(String(min));
|
let new_value = "";
|
||||||
|
|
||||||
|
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
|
||||||
|
new_value = twuiNumberfy(max, decimal).toLocaleString();
|
||||||
|
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
|
||||||
|
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||||
} else {
|
} else {
|
||||||
updateValue(
|
new_value = (
|
||||||
String(existingNumberValue + twuiNumberfy(step || DEFAULT_STEP))
|
existingNumberValue +
|
||||||
);
|
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||||
|
).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputRef.current.value = new_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrement() {
|
function decrement() {
|
||||||
const existingValue = inputRef.current?.value;
|
if (!inputRef.current) return;
|
||||||
const existingNumberValue = twuiNumberfy(existingValue);
|
|
||||||
|
|
||||||
if (min && existingNumberValue <= twuiNumberfy(min)) {
|
const existingValue = inputRef.current?.value;
|
||||||
updateValue(String(min));
|
const existingNumberValue = twuiNumberfy(existingValue, decimal);
|
||||||
|
|
||||||
|
let new_value = "";
|
||||||
|
|
||||||
|
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
|
||||||
|
new_value = twuiNumberfy(min, decimal).toLocaleString();
|
||||||
} else {
|
} else {
|
||||||
updateValue(
|
new_value = (
|
||||||
String(existingNumberValue - twuiNumberfy(step || DEFAULT_STEP))
|
existingNumberValue -
|
||||||
);
|
twuiNumberfy(step || DEFAULT_STEP, decimal)
|
||||||
|
).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputRef.current.value = new_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+108
-121
@@ -6,34 +6,31 @@ import React, {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
TextareaHTMLAttributes,
|
TextareaHTMLAttributes,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Span from "../../layout/Span";
|
import Span from "../../layout/Span";
|
||||||
import Button from "../../layout/Button";
|
|
||||||
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
|
import { Eye, EyeOff, Info, InfoIcon, X } from "lucide-react";
|
||||||
import { AutocompleteOptions } from "../../types";
|
import { AutocompleteOptions } from "../../types";
|
||||||
import twuiNumberfy from "../../utils/numberfy";
|
import twuiNumberfy from "../../utils/numberfy";
|
||||||
import Dropdown from "../../elements/Dropdown";
|
import Dropdown from "../../elements/Dropdown";
|
||||||
import Card from "../../elements/Card";
|
|
||||||
import Stack from "../../layout/Stack";
|
import Stack from "../../layout/Stack";
|
||||||
import NumberInputButtons from "./NumberInputButtons";
|
import NumberInputButtons from "./NumberInputButtons";
|
||||||
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
|
||||||
import twuiUseReady from "../../hooks/useReady";
|
|
||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import Paper from "../../elements/Paper";
|
import Paper from "../../elements/Paper";
|
||||||
import { TWUISelectValidityObject } from "../Select";
|
import { TWUISelectValidityObject } from "../Select";
|
||||||
|
|
||||||
let timeout: any;
|
export type InputProps<KeyType extends string> = Omit<
|
||||||
let validationFnTimeout: any;
|
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
|
||||||
let externalValueChangeTimeout: any;
|
"prefix" | "suffix"
|
||||||
|
|
||||||
export type InputProps<KeyType extends string> = DetailedHTMLProps<
|
|
||||||
InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
HTMLInputElement
|
|
||||||
> &
|
> &
|
||||||
DetailedHTMLProps<
|
Omit<
|
||||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
DetailedHTMLProps<
|
||||||
HTMLTextAreaElement
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
HTMLTextAreaElement
|
||||||
|
>,
|
||||||
|
"prefix" | "suffix"
|
||||||
> & {
|
> & {
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: "normal" | "warning" | "error" | "inactive";
|
variant?: "normal" | "warning" | "error" | "inactive";
|
||||||
@@ -60,21 +57,24 @@ 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;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
validity?: TWUISelectValidityObject;
|
validity?: TWUISelectValidityObject;
|
||||||
|
clearInputProps?: React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
// refreshDefaultValue?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
@@ -87,7 +87,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,
|
||||||
@@ -114,10 +114,22 @@ export default function Input<KeyType extends string>(
|
|||||||
info,
|
info,
|
||||||
changeHandler,
|
changeHandler,
|
||||||
validity: existingValidity,
|
validity: existingValidity,
|
||||||
|
clearInputProps,
|
||||||
|
rawNumber,
|
||||||
|
// refreshDefaultValue,
|
||||||
...props
|
...props
|
||||||
} = inputProps;
|
} = inputProps;
|
||||||
|
|
||||||
|
const componentRefreshesRef = useRef(0);
|
||||||
|
let timeoutRef = useRef<any>(null);
|
||||||
|
let validationFnTimeoutRef = useRef<any>(null);
|
||||||
|
let externalValueChangeTimeoutRef = useRef<any>(null);
|
||||||
|
|
||||||
|
refreshes++;
|
||||||
|
componentRefreshesRef.current++;
|
||||||
|
|
||||||
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() +
|
||||||
@@ -136,16 +148,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;
|
||||||
@@ -156,42 +171,26 @@ export default function Input<KeyType extends string>(
|
|||||||
props.placeholder ||
|
props.placeholder ||
|
||||||
(props.name ? twuiSlugToNormalText(props.name) : undefined);
|
(props.name ? twuiSlugToNormalText(props.name) : undefined);
|
||||||
|
|
||||||
function getNormalizedValue(value: string) {
|
|
||||||
if (numberText) {
|
|
||||||
if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
|
|
||||||
return getFinalValue(props.max);
|
|
||||||
|
|
||||||
if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
|
|
||||||
return getFinalValue(props.min);
|
|
||||||
|
|
||||||
return getFinalValue(value);
|
|
||||||
} else {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
if (!existingValidity) return;
|
if (!existingValidity) return;
|
||||||
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(externalValueChangeTimeoutRef.current);
|
||||||
externalValueChangeTimeout = setTimeout(() => {
|
externalValueChangeTimeoutRef.current = 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) {
|
||||||
@@ -200,23 +199,25 @@ export default function Input<KeyType extends string>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
if (validationRegex && !validationFunction) {
|
if (validationRegex) {
|
||||||
timeout = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setValidity({
|
setValidity({
|
||||||
isValid: validationRegex.test(val),
|
isValid: validationRegex.test(val),
|
||||||
msg: "Value mismatch",
|
msg: "Value mismatch",
|
||||||
});
|
});
|
||||||
}, finalDebounce);
|
}, finalDebounce);
|
||||||
} else if (validationFunction) {
|
}
|
||||||
window.clearTimeout(validationFnTimeout);
|
|
||||||
|
|
||||||
validationFnTimeout = setTimeout(() => {
|
if (validationFunction) {
|
||||||
|
window.clearTimeout(validationFnTimeoutRef.current);
|
||||||
|
|
||||||
|
validationFnTimeoutRef.current = setTimeout(() => {
|
||||||
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);
|
||||||
@@ -225,29 +226,46 @@ export default function Input<KeyType extends string>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
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 (!refreshDefaultValue) return;
|
||||||
|
// console.log("Name:", props.title || props.name);
|
||||||
|
// console.log("props.defaultValue", props.defaultValue);
|
||||||
|
// // setValue(String(props.defaultValue || ""));
|
||||||
|
// }, [refreshDefaultValue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// if (!existingReady) return;
|
||||||
|
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
|
||||||
@@ -256,9 +274,9 @@ 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,
|
||||||
)}
|
)}
|
||||||
ref={textAreaRef}
|
ref={textAreaRef}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
@@ -285,9 +303,9 @@ 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,
|
||||||
)}
|
)}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
@@ -301,6 +319,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -310,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
|
||||||
@@ -345,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 && (
|
||||||
@@ -357,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}
|
||||||
@@ -370,21 +389,19 @@ 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}
|
||||||
|
|
||||||
{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();
|
||||||
@@ -396,10 +413,11 @@ export default function Input<KeyType extends string>(
|
|||||||
textAreaRef.current.value = "";
|
textAreaRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
updateValue("");
|
setValue("");
|
||||||
|
clearInputProps?.onClick?.(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={15} />
|
<X className="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -428,73 +446,42 @@ 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}
|
value={value}
|
||||||
max={props.max}
|
max={props.max}
|
||||||
min={props.min}
|
min={props.min}
|
||||||
step={props.step}
|
step={props.step}
|
||||||
buttonDownRef={buttonDownRef}
|
buttonDownRef={buttonDownRef}
|
||||||
|
decimal={decimal}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* {info && (
|
|
||||||
<Dropdown
|
|
||||||
target={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
color="gray"
|
|
||||||
title="Input Info Button"
|
|
||||||
>
|
|
||||||
<Info
|
|
||||||
size={15}
|
|
||||||
className="opacity-50 hover:opacity-100"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
hoverOpen
|
|
||||||
>
|
|
||||||
<Card className="min-w-[250px] text-sm p-6">
|
|
||||||
{typeof info == "string" ? (
|
|
||||||
<Span className="text-sm">{info}</Span>
|
|
||||||
) : (
|
|
||||||
info
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Dropdown>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
{info && (
|
{info && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
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">
|
||||||
|
|||||||
@@ -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] =
|
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(() => {
|
||||||
dispatchState?.(value.data);
|
if (value) {
|
||||||
setInputValue(value.value);
|
dispatchState?.(value.data);
|
||||||
|
setInputValue(value.value);
|
||||||
|
changeHandler?.(value.value);
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(focusTimeout);
|
clearTimeout(focusTimeout);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
changeHandler?.(value.value);
|
|
||||||
setSelectIndex(undefined);
|
setSelectIndex(undefined);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
@@ -191,7 +200,7 @@ export default function SearchSelect<
|
|||||||
target={
|
target={
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder={props.title || "Search Options"}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
prefix={(<Search size={18} />) as any}
|
prefix={(<Search size={18} />) as any}
|
||||||
suffix={(<ChevronDown size={20} />) as any}
|
suffix={(<ChevronDown size={20} />) as any}
|
||||||
@@ -240,12 +249,13 @@ export default function SearchSelect<
|
|||||||
setSelectIndex(undefined);
|
setSelectIndex(undefined);
|
||||||
}}
|
}}
|
||||||
componentRef={inputRef}
|
componentRef={inputRef}
|
||||||
|
showLabel={showLabel}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
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(
|
||||||
@@ -254,37 +264,39 @@ 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]
|
||||||
const isTargetOption = index === selectIndex;
|
? currentOptions.map((_o, index) => {
|
||||||
const targetOptionClasses = twMerge(
|
const isTargetOption = index === selectIndex;
|
||||||
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
|
const targetOptionClasses = twMerge(
|
||||||
"twui-select-target-option"
|
"bg-background-dark dark:bg-background-light text-foreground-dark dark:text-foreground-light",
|
||||||
);
|
"twui-select-target-option"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<Button
|
<Button
|
||||||
title={_o.title || "Option"}
|
title={_o.title || "Option"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setValue(_o);
|
setValue(_o);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-full text-foreground-light dark:text-foreground-dark",
|
"w-full text-foreground-light dark:text-foreground-dark",
|
||||||
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
|
"hover:bg-gray/20 dark:hover:bg-gray-dark/20",
|
||||||
isTargetOption
|
isTargetOption
|
||||||
? targetOptionClasses
|
? targetOptionClasses
|
||||||
: ""
|
: ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{_o.value}
|
{_o.value}
|
||||||
</Button>
|
</Button>
|
||||||
<Divider />
|
<Divider />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
: null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -231,9 +232,9 @@ export default function Select<
|
|||||||
}
|
}
|
||||||
hoverOpen
|
hoverOpen
|
||||||
>
|
>
|
||||||
<Card className="min-w-[250px] text-sm p-6">
|
<Card className="min-w-[250px] p-6">
|
||||||
{typeof info == "string" ? (
|
{typeof info == "string" ? (
|
||||||
<Span className="text-sm">{info}</Span>
|
<Span>{info}</Span>
|
||||||
) : (
|
) : (
|
||||||
info
|
info
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useRef } from "react";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
elementRef?: React.RefObject<Element | undefined>;
|
elementRef?: React.RefObject<Element | undefined>;
|
||||||
@@ -9,8 +9,6 @@ type Param = {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let timeout: any;
|
|
||||||
|
|
||||||
export default function useIntersectionObserver({
|
export default function useIntersectionObserver({
|
||||||
elementRef,
|
elementRef,
|
||||||
className,
|
className,
|
||||||
@@ -19,6 +17,8 @@ export default function useIntersectionObserver({
|
|||||||
delay,
|
delay,
|
||||||
elId,
|
elId,
|
||||||
}: Param) {
|
}: Param) {
|
||||||
|
let timeoutRef = useRef<any>(null);
|
||||||
|
|
||||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||||
const [refresh, setRefresh] = React.useState(0);
|
const [refresh, setRefresh] = React.useState(0);
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ export default function useIntersectionObserver({
|
|||||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||||
(entries, observer) => {
|
(entries, observer) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
window.clearTimeout(timeout);
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
timeout = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setIsIntersecting(true);
|
setIsIntersecting(true);
|
||||||
|
|
||||||
if (removeIntersected) {
|
if (removeIntersected) {
|
||||||
@@ -41,7 +41,7 @@ export default function useIntersectionObserver({
|
|||||||
setIsIntersecting(false);
|
setIsIntersecting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
initialLoading?: boolean;
|
||||||
|
initialReady?: boolean;
|
||||||
|
initialOpen?: 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);
|
||||||
|
const [open, setOpen] = React.useState(params?.initialOpen || false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh,
|
||||||
|
setRefresh,
|
||||||
|
loading,
|
||||||
|
setLoading,
|
||||||
|
status,
|
||||||
|
setStatus,
|
||||||
|
ready,
|
||||||
|
setReady,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
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;
|
||||||
|
/** Interval in ms to force-refresh the connection */
|
||||||
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
|
||||||
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
||||||
*
|
*
|
||||||
* @example window.addEventLiatener("wsDataEvent", (e)=>{
|
* @example window.addEventListener("wsDataEvent", (e)=>{
|
||||||
* console.log(e.detail.data) // type object
|
* console.log(e.detail.data) // type object
|
||||||
* })
|
* })
|
||||||
* @example window.addEventLiatener("wsMessageEvent", (e)=>{
|
* @example window.addEventListener("wsMessageEvent", (e)=>{
|
||||||
* console.log(e.detail.message) // type string
|
* console.log(e.detail.message) // type string
|
||||||
* })
|
* })
|
||||||
*/
|
*/
|
||||||
@@ -33,22 +33,31 @@ 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_MESSAGE = "twui::ping";
|
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||||
|
|
||||||
let uptime = 0;
|
const tries = useRef(0);
|
||||||
|
|
||||||
let reconnectInterval: any;
|
// Refs to avoid stale closures in callbacks
|
||||||
let msgInterval: any;
|
const urlRef = useRef(url);
|
||||||
let sendInterval: any;
|
const disableReconnectRef = useRef(disableReconnect);
|
||||||
let keepAliveInterval: any;
|
const keepAliveDurationRef = useRef(KEEP_ALIVE_DURATION);
|
||||||
|
|
||||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(
|
React.useEffect(() => {
|
||||||
undefined
|
urlRef.current = url;
|
||||||
);
|
disableReconnectRef.current = disableReconnect;
|
||||||
|
keepAliveDurationRef.current = KEEP_ALIVE_DURATION;
|
||||||
|
});
|
||||||
|
|
||||||
|
const msgInterval = useRef<any>(null);
|
||||||
|
const sendInterval = useRef<any>(null);
|
||||||
|
const keepAliveInterval = useRef<any>(null);
|
||||||
|
const refreshInterval = useRef<any>(null);
|
||||||
|
const reconnectTimeout = useRef<any>(null);
|
||||||
|
|
||||||
|
const [socket, setSocket] = React.useState<WebSocket | undefined>(undefined);
|
||||||
|
const socketRef = useRef<WebSocket | undefined>(undefined);
|
||||||
|
|
||||||
const messageQueueRef = React.useRef<string[]>([]);
|
const messageQueueRef = React.useRef<string[]>([]);
|
||||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||||
@@ -73,142 +82,121 @@ export default function useWebSocket<
|
|||||||
* # Connect to Websocket
|
* # Connect to Websocket
|
||||||
*/
|
*/
|
||||||
const connect = React.useCallback(() => {
|
const connect = React.useCallback(() => {
|
||||||
const wsURL = url;
|
const currentUrl = urlRef.current;
|
||||||
|
const domain = window.location.origin;
|
||||||
|
const wsURL = currentUrl.startsWith("ws")
|
||||||
|
? currentUrl
|
||||||
|
: domain.replace(/^http/, "ws") + ("/" + currentUrl).replace(/\/\//g, "/");
|
||||||
|
|
||||||
if (!wsURL) return;
|
if (!wsURL) return;
|
||||||
|
|
||||||
let ws = new WebSocket(wsURL);
|
const ws = new WebSocket(wsURL);
|
||||||
|
|
||||||
ws.onopen = (ev) => {
|
ws.onerror = () => {
|
||||||
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 = () => {
|
||||||
}
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
|
||||||
|
keepAliveInterval.current = window.setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(KEEP_ALIVE_MESSAGE);
|
||||||
|
}
|
||||||
|
}, keepAliveDurationRef.current);
|
||||||
|
|
||||||
|
tries.current = 0;
|
||||||
|
socketRef.current = ws;
|
||||||
|
setSocket(ws);
|
||||||
|
console.log(`Websocket connected to ${wsURL}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
ws.onclose = (ev) => {
|
||||||
console.log("Websocket closed!");
|
console.log("Websocket closed!", {
|
||||||
|
code: ev.code,
|
||||||
|
reason: ev.reason,
|
||||||
|
wasClean: ev.wasClean,
|
||||||
|
});
|
||||||
|
|
||||||
if (disableReconnect) return;
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
socketRef.current = undefined;
|
||||||
|
setSocket(undefined);
|
||||||
|
|
||||||
console.log("Attempting to reconnect ...");
|
if (disableReconnectRef.current) return;
|
||||||
console.log("URL:", url);
|
|
||||||
window.clearInterval(keepAliveInterval);
|
|
||||||
|
|
||||||
reconnectInterval = setInterval(() => {
|
if (tries.current >= 3) {
|
||||||
if (tries >= 3) {
|
console.log("Max reconnect attempts reached.");
|
||||||
return window.clearInterval(reconnectInterval);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Attempting to reconnect ...");
|
tries.current += 1;
|
||||||
tries++;
|
const backoff = Math.min(1000 * 2 ** tries.current, 30000);
|
||||||
|
console.log(`Attempting to reconnect in ${backoff}ms... (attempt ${tries.current})`);
|
||||||
connect();
|
reconnectTimeout.current = window.setTimeout(connect, backoff);
|
||||||
}, 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();
|
connect();
|
||||||
|
|
||||||
return function () {
|
return () => {
|
||||||
window.clearInterval(reconnectInterval);
|
window.clearTimeout(reconnectTimeout.current);
|
||||||
|
window.clearInterval(keepAliveInterval.current);
|
||||||
|
window.clearInterval(refreshInterval.current);
|
||||||
|
socketRef.current?.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Window Close and Focus Handlers
|
* # Refresh Connection Interval
|
||||||
*/
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!refreshConnection) return;
|
||||||
|
|
||||||
|
refreshInterval.current = window.setInterval(() => {
|
||||||
|
console.log("Refreshing WebSocket connection...");
|
||||||
|
window.clearTimeout(reconnectTimeout.current);
|
||||||
|
socketRef.current?.close();
|
||||||
|
tries.current = 0;
|
||||||
|
connect();
|
||||||
|
}, refreshConnection);
|
||||||
|
|
||||||
|
return () => window.clearInterval(refreshInterval.current);
|
||||||
|
}, [refreshConnection]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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 () => {
|
||||||
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) {
|
try {
|
||||||
const newMessage = messageQueueRef.current.shift();
|
const msg = messageQueueRef.current.shift();
|
||||||
if (!newMessage) return;
|
|
||||||
try {
|
if (!msg) return;
|
||||||
const jsonData = JSON.parse(newMessage);
|
|
||||||
dispatchCustomEvent("wsMessageEvent", newMessage);
|
const jsonData = JSON.parse(msg);
|
||||||
dispatchCustomEvent("wsDataEvent", jsonData);
|
dispatchCustomEvent("wsMessageEvent", msg);
|
||||||
} catch (error) {
|
dispatchCustomEvent("wsDataEvent", jsonData);
|
||||||
console.log("Unable to parse string. Returning string.");
|
} catch (error) {
|
||||||
}
|
console.log("Unable to parse string. Returning string.");
|
||||||
} else {
|
|
||||||
window.clearInterval(msgInterval);
|
|
||||||
uptime = 0;
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -216,30 +204,35 @@ export default function useWebSocket<
|
|||||||
* Send Message Queue Handler
|
* Send Message Queue Handler
|
||||||
*/
|
*/
|
||||||
const handleSendMessageQueue = React.useCallback(() => {
|
const handleSendMessageQueue = React.useCallback(() => {
|
||||||
if (sendMessageQueueRef.current.length > 0) {
|
const ws = socketRef.current;
|
||||||
const newMessage = sendMessageQueueRef.current.shift();
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
if (!newMessage) return;
|
window.clearInterval(sendInterval.current);
|
||||||
socket?.send(newMessage);
|
return;
|
||||||
} else {
|
|
||||||
window.clearInterval(sendInterval);
|
|
||||||
}
|
}
|
||||||
}, [socket]);
|
|
||||||
|
const newMessage = sendMessageQueueRef.current.shift();
|
||||||
|
if (!newMessage) return;
|
||||||
|
|
||||||
|
ws.send(newMessage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # Send Data Function
|
* # Send Data Function
|
||||||
*/
|
*/
|
||||||
const sendData = React.useCallback(
|
const sendData = React.useCallback((data: T) => {
|
||||||
(data: T) => {
|
try {
|
||||||
try {
|
const queueItemJSON = JSON.stringify(data);
|
||||||
window.clearInterval(sendInterval);
|
|
||||||
sendMessageQueueRef.current.push(JSON.stringify(data));
|
const existingQueue = sendMessageQueueRef.current.find(
|
||||||
sendInterval = setInterval(handleSendMessageQueue, DEBOUNCE);
|
(q) => q === queueItemJSON
|
||||||
} catch (error: any) {
|
);
|
||||||
console.log("Error Sending socket message", error.message);
|
if (!existingQueue) {
|
||||||
|
sendMessageQueueRef.current.push(queueItemJSON);
|
||||||
}
|
}
|
||||||
},
|
} catch (error: any) {
|
||||||
[socket]
|
console.log("Error Sending socket message", error.message);
|
||||||
);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { socket, sendData };
|
return { socket, sendData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] =
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import React, {
|
||||||
AnchorHTMLAttributes,
|
AnchorHTMLAttributes,
|
||||||
ButtonHTMLAttributes,
|
ButtonHTMLAttributes,
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
@@ -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 | React.JSX.Element;
|
||||||
afterIcon?: React.ReactNode;
|
afterIcon?: TWUILucideIconName | React.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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,17 +259,17 @@ export default function Button({
|
|||||||
props.disabled ? "opacity-40 cursor-not-allowed" : "",
|
props.disabled ? "opacity-40 cursor-not-allowed" : "",
|
||||||
"twui-button-general",
|
"twui-button-general",
|
||||||
size == "small"
|
size == "small"
|
||||||
? "px-3 py-1.5 text-sm twui-button-small"
|
? "px-3 py-1.5 twui-button-small text-sm"
|
||||||
: size == "smaller"
|
: size == "smaller"
|
||||||
? "px-2 py-1 text-xs twui-button-smaller"
|
? "px-2 py-1 text-xs twui-button-smaller"
|
||||||
: size == "large"
|
: size == "large"
|
||||||
? "text-lg twui-button-large"
|
? "text-lg twui-button-large"
|
||||||
: size == "larger"
|
: size == "larger"
|
||||||
? "px-5 py-3 text-xl twui-button-larger"
|
? "px-5 py-3 text-xl twui-button-larger"
|
||||||
: "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 && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function Container({
|
|||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex w-full max-w-[1200px] gap-4 justify-between",
|
"flex w-full max-w-container gap-4 justify-between",
|
||||||
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
|
"flex-wrap flex-col xl:flex-row items-start xl:items-center",
|
||||||
"twui-container",
|
"twui-container",
|
||||||
props.className
|
props.className
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function H5({
|
|||||||
<h5
|
<h5
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-sm mb-4",
|
"mb-4",
|
||||||
"twui-headings twui-heading",
|
"twui-headings twui-heading",
|
||||||
"twui-h5",
|
"twui-h5",
|
||||||
props.className
|
props.className
|
||||||
|
|||||||
@@ -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 (
|
||||||
<img
|
imgErrComp || (
|
||||||
loading="lazy"
|
<img
|
||||||
{...interpolatedProps}
|
loading="lazy"
|
||||||
src={
|
{...interpolatedProps}
|
||||||
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
src={
|
||||||
}
|
imgErrSrc ||
|
||||||
/>
|
"https://static.datasquirel.com/images/user-images/user-2/castcord-image-preset_thumbnail.jpg"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function Spacer({ horizontal, ...props }: Props) {
|
|||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"grow",
|
"",
|
||||||
horizontal ? "w-10" : "w-full h-10",
|
horizontal ? "w-10" : "w-full h-10",
|
||||||
"twui-spacer",
|
"twui-spacer",
|
||||||
props.className
|
props.className
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps, useRef } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import MarkdownEditorPreviewComponent from "./MarkdownEditorPreviewComponent";
|
import MarkdownEditorPreviewComponent from "./MarkdownEditorPreviewComponent";
|
||||||
import MarkdownEditorComponent from "./MarkdownEditorComponent";
|
import MarkdownEditorComponent from "./MarkdownEditorComponent";
|
||||||
@@ -6,6 +6,7 @@ import MarkdownEditorSelectorButtons from "./MarkdownEditorSelectorButtons";
|
|||||||
import Row from "../../layout/Row";
|
import Row from "../../layout/Row";
|
||||||
import Stack from "../../layout/Stack";
|
import Stack from "../../layout/Stack";
|
||||||
import AceEditor from "../../editors/AceEditor";
|
import AceEditor from "../../editors/AceEditor";
|
||||||
|
import useStatus from "../../hooks/useStatus";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -14,6 +15,7 @@ type Props = {
|
|||||||
changeHandler?: (content: string) => void;
|
changeHandler?: (content: string) => void;
|
||||||
editorProps?: ComponentProps<typeof AceEditor>;
|
editorProps?: ComponentProps<typeof AceEditor>;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
|
noToggleButtons?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MarkdownEditor({
|
export default function MarkdownEditor({
|
||||||
@@ -23,42 +25,58 @@ export default function MarkdownEditor({
|
|||||||
changeHandler,
|
changeHandler,
|
||||||
editorProps,
|
editorProps,
|
||||||
maxHeight: existingMaxHeight,
|
maxHeight: existingMaxHeight,
|
||||||
|
noToggleButtons,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [value, setValue] = React.useState<any>(existingValue || ``);
|
const [value, setValue] = React.useState<any>(existingValue || ``);
|
||||||
|
|
||||||
const [sideBySide, setSideBySide] = React.useState(
|
const [sideBySide, setSideBySide] = React.useState(
|
||||||
defaultSideBySide || false
|
defaultSideBySide || false,
|
||||||
);
|
);
|
||||||
const [preview, setPreview] = React.useState(false);
|
const [preview, setPreview] = React.useState(false);
|
||||||
|
const { refresh, setRefresh } = useStatus();
|
||||||
|
const updatingFromExtValueRef = useRef(false);
|
||||||
|
|
||||||
const maxHeight = existingMaxHeight || "600px";
|
const maxHeight = existingMaxHeight || "600px";
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (updatingFromExtValueRef.current) return;
|
||||||
setExistingValue?.(value);
|
setExistingValue?.(value);
|
||||||
changeHandler?.(value);
|
changeHandler?.(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!existingValue) return;
|
||||||
|
updatingFromExtValueRef.current = true;
|
||||||
|
setValue(existingValue);
|
||||||
|
setTimeout(() => {
|
||||||
|
updatingFromExtValueRef.current = false;
|
||||||
|
setRefresh((prev) => prev + 1);
|
||||||
|
}, 500);
|
||||||
|
}, [existingValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full items-stretch">
|
<Stack className="w-full items-stretch">
|
||||||
<MarkdownEditorSelectorButtons
|
{!noToggleButtons && (
|
||||||
{...{ preview, setPreview, setSideBySide, sideBySide }}
|
<MarkdownEditorSelectorButtons
|
||||||
/>
|
{...{ preview, setPreview, setSideBySide, sideBySide }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{sideBySide ? (
|
{sideBySide ? (
|
||||||
<Row
|
<Row
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
`w-full grid xl:grid-cols-2 gap-6 max-h-[${maxHeight}]`,
|
`w-full grid xl:grid-cols-2 gap-6 max-h-[${maxHeight}]`,
|
||||||
"overflow-auto"
|
"overflow-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MarkdownEditorComponent
|
<MarkdownEditorComponent
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
value={value}
|
value={value}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
|
refreshDepArr={[refresh]}
|
||||||
{...editorProps}
|
{...editorProps}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditorPreviewComponent
|
<MarkdownEditorPreviewComponent
|
||||||
setValue={setValue}
|
|
||||||
value={value}
|
value={value}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +87,6 @@ export default function MarkdownEditor({
|
|||||||
>
|
>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<MarkdownEditorPreviewComponent
|
<MarkdownEditorPreviewComponent
|
||||||
setValue={setValue}
|
|
||||||
value={value}
|
value={value}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
/>
|
/>
|
||||||
@@ -78,6 +95,7 @@ export default function MarkdownEditor({
|
|||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
value={value}
|
value={value}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
|
refreshDepArr={[refresh]}
|
||||||
{...editorProps}
|
{...editorProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { ComponentProps, RefObject } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { serialize } from "next-mdx-remote/serialize";
|
import { serialize } from "next-mdx-remote/serialize";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -10,14 +10,14 @@ import EmptyContent from "../../elements/EmptyContent";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
setValue: React.Dispatch<any>;
|
|
||||||
maxHeight: string;
|
maxHeight: string;
|
||||||
|
wrapperProps?: ComponentProps<typeof Border>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MarkdownEditorPreviewComponent({
|
export default function MarkdownEditorPreviewComponent({
|
||||||
value,
|
value,
|
||||||
setValue,
|
|
||||||
maxHeight,
|
maxHeight,
|
||||||
|
wrapperProps,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
try {
|
try {
|
||||||
const [mdxSource, setMdxSource] =
|
const [mdxSource, setMdxSource] =
|
||||||
@@ -48,17 +48,17 @@ 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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Border
|
<Border
|
||||||
|
{...wrapperProps}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
|
`w-full max-h-[${maxHeight}] h-[${maxHeight}] block px-6 pb-10`,
|
||||||
"overflow-auto"
|
"overflow-auto",
|
||||||
|
wrapperProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mdxSource ? (
|
{mdxSource ? (
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ export function useMDXComponents(params?: Params) {
|
|||||||
pre: ({ children, ...props }) => {
|
pre: ({ children, ...props }) => {
|
||||||
if (React.isValidElement(children) && children.props) {
|
if (React.isValidElement(children) && children.props) {
|
||||||
return (
|
return (
|
||||||
|
// @ts-ignore
|
||||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
{children.props.children}
|
{children.props.children}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
// @ts-ignore
|
||||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
{children}
|
{children}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
@@ -61,6 +64,7 @@ export function useMDXComponents(params?: Params) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
img: (props) => (
|
img: (props) => (
|
||||||
|
// @ts-ignore
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
className="w-full h-auto shadow-lg rounded-default overflow-hidden"
|
className="w-full h-auto shadow-lg rounded-default overflow-hidden"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function useMDXComponents({
|
|||||||
pre: ({ children, ...props }) => {
|
pre: ({ children, ...props }) => {
|
||||||
if (React.isValidElement(children) && children.props) {
|
if (React.isValidElement(children) && children.props) {
|
||||||
return (
|
return (
|
||||||
|
// @ts-ignore
|
||||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
{children.props.children}
|
{children.props.children}
|
||||||
@@ -29,6 +30,7 @@ export default function useMDXComponents({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
// @ts-ignore
|
||||||
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
<CodeBlock {...props} backgroundColor={codeBgColor}>
|
||||||
{children}
|
{children}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
import twuiSerializeQuery from "../serialize-query";
|
||||||
|
|
||||||
|
export const FetchAPIMethods = [
|
||||||
|
"POST",
|
||||||
|
"GET",
|
||||||
|
"DELETE",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"post",
|
||||||
|
"get",
|
||||||
|
"delete",
|
||||||
|
"put",
|
||||||
|
"patch",
|
||||||
|
] as const;
|
||||||
|
|
||||||
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
|
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
|
||||||
method:
|
method: (typeof FetchAPIMethods)[number];
|
||||||
| "POST"
|
|
||||||
| "GET"
|
|
||||||
| "DELETE"
|
|
||||||
| "PUT"
|
|
||||||
| "PATCH"
|
|
||||||
| "post"
|
|
||||||
| "get"
|
|
||||||
| "delete"
|
|
||||||
| "put"
|
|
||||||
| "patch";
|
|
||||||
body?: T | string;
|
body?: T | string;
|
||||||
headers?: FetchHeader;
|
headers?: FetchHeader;
|
||||||
|
query?: T;
|
||||||
|
csrfValue?: string;
|
||||||
|
csrfKey?: string;
|
||||||
|
fetchOptions?: RequestInit;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchHeader = HeadersInit & {
|
type FetchHeader = HeadersInit & {
|
||||||
@@ -32,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") {
|
||||||
@@ -66,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);
|
||||||
@@ -74,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;
|
||||||
}
|
}
|
||||||
@@ -95,14 +93,15 @@ 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();
|
||||||
@@ -112,7 +111,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);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default async function fileInputToBase64({
|
|||||||
resolve(reader.result?.toString());
|
resolve(reader.result?.toString());
|
||||||
};
|
};
|
||||||
reader.onerror = function (/** @type {*} */ error: any) {
|
reader.onerror = function (/** @type {*} */ error: any) {
|
||||||
console.log("Error: ", error.message);
|
console.log("File Input to Base64 Error: ", error.message);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const work = {
|
|||||||
description: "Clould-based SQL data management system.",
|
description: "Clould-based SQL data management system.",
|
||||||
href: "https://datasquirel.com",
|
href: "https://datasquirel.com",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: [
|
technologies: [
|
||||||
"Node JS",
|
"Node JS",
|
||||||
"SQL",
|
"SQL",
|
||||||
@@ -24,6 +25,7 @@ export const work = {
|
|||||||
description: "Mortgage Broker in Utah",
|
description: "Mortgage Broker in Utah",
|
||||||
href: "https://summitlending.com",
|
href: "https://summitlending.com",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: ["500+ leads/month", "99.99% uptime"],
|
||||||
technologies: [
|
technologies: [
|
||||||
"Next JS",
|
"Next JS",
|
||||||
"Tailwind CSS",
|
"Tailwind CSS",
|
||||||
@@ -38,32 +40,71 @@ export const work = {
|
|||||||
description: "A new age of remote work. Targeted at developers",
|
description: "A new age of remote work. Targeted at developers",
|
||||||
href: "https://coderank.net",
|
href: "https://coderank.net",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: [
|
technologies: [
|
||||||
"Next JS",
|
"Next JS",
|
||||||
"Docker",
|
"Docker",
|
||||||
"VSCode Web Editor",
|
"VSCode Web Editor",
|
||||||
"NGINX Reverse Proxy",
|
"NGINX",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Ifuekosa LLC",
|
title: "Mediajury",
|
||||||
image: "/images/work/devops/server-management.png",
|
description: "The ultimate debate platform",
|
||||||
description:
|
href: "https://mediajury.org",
|
||||||
"Tax Preparation, Notary and Business Consulting Services in New Jersey",
|
metrics: [""],
|
||||||
href: "https://ifuekosallc.com/",
|
technologies: [
|
||||||
technologies: ["Wordpress", "Docker", "Email Server"],
|
"SQL",
|
||||||
|
"MariaDB",
|
||||||
|
"Next JS",
|
||||||
|
"Docker",
|
||||||
|
"NGINX",
|
||||||
|
"Typescript",
|
||||||
|
"Bun JS",
|
||||||
|
"Tailwind CSS",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Circlenav",
|
||||||
|
description: "The AI search engine done right",
|
||||||
|
href: "https://circlenav.net",
|
||||||
|
metrics: [""],
|
||||||
|
technologies: [
|
||||||
|
"SearXNG",
|
||||||
|
"NextJS",
|
||||||
|
"Docker",
|
||||||
|
"Grok (XAI)",
|
||||||
|
"Websockets",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
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",
|
||||||
|
metrics: [""],
|
||||||
|
technologies: [
|
||||||
|
"Bun",
|
||||||
|
"Shell",
|
||||||
|
"Typescript",
|
||||||
|
"APIs",
|
||||||
|
"Hetzner",
|
||||||
|
"AWS",
|
||||||
|
"GCP",
|
||||||
|
"Azure",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Personal Mail Server",
|
title: "Personal Mail Server",
|
||||||
description:
|
description:
|
||||||
"Self Hosted Email solution for all my personal projects",
|
"Self Hosted Email solution for all my personal projects",
|
||||||
href: "https://box.mailben.xyz/mail",
|
href: "https://box.mailben.xyz/mail",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: ["Linux", "Docker", "Mailinabox"],
|
technologies: ["Linux", "Docker", "Mailinabox"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,6 +113,7 @@ export const work = {
|
|||||||
"Self Hosted repository for Git projects, NPM modules, Docker images, and more",
|
"Self Hosted repository for Git projects, NPM modules, Docker images, and more",
|
||||||
href: "https://git.tben.me/tben",
|
href: "https://git.tben.me/tben",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: ["Gitea", "Linux"],
|
technologies: ["Gitea", "Linux"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -81,11 +123,35 @@ 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",
|
||||||
|
metrics: [""],
|
||||||
|
technologies: [
|
||||||
|
"Bun",
|
||||||
|
"Shell",
|
||||||
|
"Typescript",
|
||||||
|
"APIs",
|
||||||
|
"Hetzner",
|
||||||
|
"AWS",
|
||||||
|
"GCP",
|
||||||
|
"Azure",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bun SQLite",
|
||||||
|
description:
|
||||||
|
"A schema-driven SQLite manager for Bun, featuring automatic schema synchronization, type-safe CRUD operations, vector embedding support, and TypeScript type definition generation.",
|
||||||
|
href: "https://git.tben.me/Moduletrace/bun-sqlite",
|
||||||
|
technologies: ["SQLite", "Bun", "Shell", "Typescript"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: ["Node JS", "Bun JS", "Shell Scripting", "Rsync"],
|
technologies: ["Node JS", "Bun JS", "Shell Scripting", "Rsync"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -93,6 +159,7 @@ export const work = {
|
|||||||
description: "Run multiple concurrent processes",
|
description: "Run multiple concurrent processes",
|
||||||
href: "https://git.tben.me/Moduletrace/batchrun",
|
href: "https://git.tben.me/Moduletrace/batchrun",
|
||||||
image: "/images/work/devops/server-management.png",
|
image: "/images/work/devops/server-management.png",
|
||||||
|
metrics: [""],
|
||||||
technologies: ["Node JS"],
|
technologies: ["Node JS"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
import H2 from "@/components/lib/layout/H2";
|
import H2 from "@/components/lib/layout/H2";
|
||||||
|
import Link from "@/components/lib/layout/Link";
|
||||||
|
import Row from "@/components/lib/layout/Row";
|
||||||
import Section from "@/components/lib/layout/Section";
|
import Section from "@/components/lib/layout/Section";
|
||||||
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 { UserRoundSearch } from "lucide-react";
|
||||||
|
|
||||||
export default function AboutSection() {
|
export default function AboutSection() {
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
||||||
<H2 className="leading-snug">About Me</H2>
|
<H2 className="leading-snug">
|
||||||
|
I Build & Operate Production Systems
|
||||||
|
</H2>
|
||||||
<Span>
|
<Span>
|
||||||
I'm passionate and dedicated to solving problems using the
|
I'm Benjamin Toby. DevOps/Platform engineer and CTO at
|
||||||
best technologies available.
|
Summit Lending. I design, deploy, and operate infrastructure
|
||||||
|
that supports real businesses:
|
||||||
</Span>
|
</Span>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
<li>Automated CI/CD pipelines (TurboCI)</li>
|
||||||
|
<li>Multi-cloud deployments (AWS, GCP, Azure, Hetzner)</li>
|
||||||
|
<li>NGINX hardening, TLS, caching, rate limiting</li>
|
||||||
|
<li>Full-stack delivery (Next.js, Bun, SQL)</li>
|
||||||
|
</ul>
|
||||||
|
<Span>
|
||||||
|
I focus on measurable outcomes: faster deployments, lower
|
||||||
|
costs, fewer incidents.
|
||||||
|
</Span>
|
||||||
|
|
||||||
|
<Link className="dotted-text" href="/about">
|
||||||
|
<Row>
|
||||||
|
<UserRoundSearch size={19} />
|
||||||
|
<Span>View Resume</Span>
|
||||||
|
</Row>
|
||||||
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import Span from "@/components/lib/layout/Span";
|
|||||||
import Stack from "@/components/lib/layout/Stack";
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
import { work } from "../(data)/work";
|
import { work } from "../(data)/work";
|
||||||
import Link from "@/components/lib/layout/Link";
|
import Link from "@/components/lib/layout/Link";
|
||||||
import React from "react";
|
import React, { Fragment } from "react";
|
||||||
import Row from "@/components/lib/layout/Row";
|
import Row from "@/components/lib/layout/Row";
|
||||||
import Divider from "@/components/lib/layout/Divider";
|
import Divider from "@/components/lib/layout/Divider";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { CheckCircle2, Circle } from "lucide-react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
noTitle?: boolean;
|
noTitle?: boolean;
|
||||||
@@ -19,7 +20,7 @@ type Props = {
|
|||||||
export default function MyWorkSection({ noTitle, expand }: Props) {
|
export default function MyWorkSection({ noTitle, expand }: Props) {
|
||||||
const categories = Object.keys(work) as (keyof typeof work)[];
|
const categories = Object.keys(work) as (keyof typeof work)[];
|
||||||
const [category, setCategory] = React.useState<keyof typeof work>(
|
const [category, setCategory] = React.useState<keyof typeof work>(
|
||||||
categories[0]
|
categories[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (expand) {
|
if (expand) {
|
||||||
@@ -53,7 +54,7 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
|
|||||||
key={index}
|
key={index}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -88,7 +89,7 @@ export default function MyWorkSection({ noTitle, expand }: Props) {
|
|||||||
"cursor-pointer",
|
"cursor-pointer",
|
||||||
isActive
|
isActive
|
||||||
? ""
|
? ""
|
||||||
: "opacity-40 hover:opacity-70"
|
: "opacity-40 hover:opacity-70",
|
||||||
)}
|
)}
|
||||||
onClick={() => setCategory(ctgr)}
|
onClick={() => setCategory(ctgr)}
|
||||||
>
|
>
|
||||||
@@ -129,24 +130,44 @@ export function MyWorkPortfolioCard({
|
|||||||
<Link
|
<Link
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={portfolio.href}
|
href={portfolio.href}
|
||||||
className="text-sm text-wrap break-all border-none"
|
className="text-sm text-wrap break-all border- dotted-text"
|
||||||
>
|
>
|
||||||
{portfolio.href}
|
{portfolio.href}
|
||||||
</Link>
|
</Link>
|
||||||
<Span>{portfolio.description}</Span>
|
<Span>{portfolio.description}</Span>
|
||||||
|
{portfolio.metrics?.[0] && (
|
||||||
|
<Fragment>
|
||||||
|
<Divider />
|
||||||
|
<Row className="gap-4">
|
||||||
|
{portfolio.metrics.map((tch, _i) => (
|
||||||
|
<React.Fragment key={_i}>
|
||||||
|
<Row>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
<Span className="text-xs dark:text-white/40">
|
||||||
|
{tch}
|
||||||
|
</Span>
|
||||||
|
</Row>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
{portfolio.technologies?.[0] && (
|
{portfolio.technologies?.[0] && (
|
||||||
<Row className="gap-4">
|
<Fragment>
|
||||||
{portfolio.technologies.map((tch, _i) => (
|
<Divider />
|
||||||
<React.Fragment key={_i}>
|
<Row className="gap-4 opacity-60">
|
||||||
<Span className="text-sm dark:text-white/40">
|
{portfolio.technologies.map((tch, _i) => (
|
||||||
{tch}
|
<React.Fragment key={_i}>
|
||||||
</Span>
|
<Span className="text-xs dark:text-white/40">
|
||||||
{_i < portfolio.technologies.length - 1 && (
|
{tch}
|
||||||
<Divider vertical />
|
</Span>
|
||||||
)}
|
{_i < portfolio.technologies.length - 1 && (
|
||||||
</React.Fragment>
|
<Divider vertical />
|
||||||
))}
|
)}
|
||||||
</Row>
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Button from "@/components/lib/layout/Button";
|
||||||
|
import H2 from "@/components/lib/layout/H2";
|
||||||
|
import Row from "@/components/lib/layout/Row";
|
||||||
|
import Section from "@/components/lib/layout/Section";
|
||||||
|
import Span from "@/components/lib/layout/Span";
|
||||||
|
import Stack from "@/components/lib/layout/Stack";
|
||||||
|
import { Contact, Mail } from "lucide-react";
|
||||||
|
|
||||||
|
export default function FooterCTASection() {
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8">
|
||||||
|
<H2 className="leading-snug">Need Help With Infrastructure?</H2>
|
||||||
|
<Span>
|
||||||
|
Whether it's CI/CD, cost optimization, or production
|
||||||
|
hardening, I can help.
|
||||||
|
</Span>
|
||||||
|
|
||||||
|
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
|
||||||
|
<Button
|
||||||
|
title="Contact Me"
|
||||||
|
beforeIcon={
|
||||||
|
<Contact size={17} className="font-normal" />
|
||||||
|
}
|
||||||
|
href="/contact"
|
||||||
|
className="grow w-full"
|
||||||
|
>
|
||||||
|
Schedule a Call
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="View My Work"
|
||||||
|
beforeIcon={<Mail size={17} className="font-normal" />}
|
||||||
|
href={`mailto:${process.env.NEXT_PUBLIC_EMAIL_ADDRESS}`}
|
||||||
|
className="grow w-full bg-transparent!"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
Email Me
|
||||||
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
beforeIcon={
|
||||||
|
<ScrollText size={17} className="font-normal" />
|
||||||
|
}
|
||||||
|
href="/contact"
|
||||||
|
variant="outlined"
|
||||||
|
className="grow w-full"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Button> */}
|
||||||
|
</Row>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,42 @@
|
|||||||
|
import LucideIcon from "@/components/lib/elements/lucide-icon";
|
||||||
import Button from "@/components/lib/layout/Button";
|
import Button from "@/components/lib/layout/Button";
|
||||||
|
import Divider from "@/components/lib/layout/Divider";
|
||||||
import H1 from "@/components/lib/layout/H1";
|
import H1 from "@/components/lib/layout/H1";
|
||||||
import Row from "@/components/lib/layout/Row";
|
import Row from "@/components/lib/layout/Row";
|
||||||
import Section from "@/components/lib/layout/Section";
|
import Section from "@/components/lib/layout/Section";
|
||||||
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 { Contact, ScrollText } from "lucide-react";
|
import { Contact, ScrollText, Terminal } from "lucide-react";
|
||||||
|
|
||||||
export default function Main() {
|
export default function Main() {
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
||||||
<Span>Howdy Tech Enthusiasts! I'm Benjamin Toby</Span>
|
<Span>Benjamin Toby</Span>
|
||||||
<H1 className="leading-snug">
|
<H1 className="leading-snug m-0!">
|
||||||
Software Engineer, DevOps Engineer, Full Stack Developer,
|
DevOps & Platform Engineer
|
||||||
Software Architect, Philosopher, Solar Energy Enthusiast.
|
|
||||||
</H1>
|
</H1>
|
||||||
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto">
|
<Span className="text-xl">
|
||||||
|
I help teams ship faster, cut infrastructure costs, and
|
||||||
|
achieve reliable production systems.
|
||||||
|
</Span>
|
||||||
|
<Row className="my-4 gap-6">
|
||||||
|
<Row>
|
||||||
|
<LucideIcon name="CheckCircle2" size={20} />
|
||||||
|
<Span>99.99% uptime targets</Span>
|
||||||
|
</Row>
|
||||||
|
<Divider vertical />
|
||||||
|
<Row>
|
||||||
|
<LucideIcon name="CheckCircle2" size={20} />
|
||||||
|
<Span>~10 deployments/week</Span>
|
||||||
|
</Row>
|
||||||
|
<Divider vertical />
|
||||||
|
<Row>
|
||||||
|
<LucideIcon name="CheckCircle2" size={20} />
|
||||||
|
<Span>Over 80% Operations cost reduction</Span>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
|
||||||
<Button
|
<Button
|
||||||
title="Contact Me"
|
title="Contact Me"
|
||||||
beforeIcon={
|
beforeIcon={
|
||||||
@@ -24,7 +45,18 @@ export default function Main() {
|
|||||||
href="/contact"
|
href="/contact"
|
||||||
className="grow w-full"
|
className="grow w-full"
|
||||||
>
|
>
|
||||||
Contact Me
|
Book a Consultation
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="View My Work"
|
||||||
|
beforeIcon={
|
||||||
|
<Terminal size={17} className="font-normal" />
|
||||||
|
}
|
||||||
|
href="/work"
|
||||||
|
className="grow w-full bg-transparent!"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
View My Work
|
||||||
</Button>
|
</Button>
|
||||||
{/* <Button
|
{/* <Button
|
||||||
beforeIcon={
|
beforeIcon={
|
||||||
|
|||||||
@@ -1,21 +1,76 @@
|
|||||||
|
import Button from "@/components/lib/layout/Button";
|
||||||
|
import Divider from "@/components/lib/layout/Divider";
|
||||||
import H1 from "@/components/lib/layout/H1";
|
import H1 from "@/components/lib/layout/H1";
|
||||||
|
import H2 from "@/components/lib/layout/H2";
|
||||||
import Link from "@/components/lib/layout/Link";
|
import Link from "@/components/lib/layout/Link";
|
||||||
|
import P from "@/components/lib/layout/P";
|
||||||
import Row from "@/components/lib/layout/Row";
|
import Row from "@/components/lib/layout/Row";
|
||||||
import Section from "@/components/lib/layout/Section";
|
import Section from "@/components/lib/layout/Section";
|
||||||
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 { Mail } from "lucide-react";
|
import { Terminal } from "lucide-react";
|
||||||
|
|
||||||
export default function Main() {
|
export default function Main() {
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
<Stack className="w-full max-w-full xl:max-w-[50vw] gap-8">
|
||||||
|
{/* <Span className="leading-snug -mb-6 text-sm">
|
||||||
|
I Build & Operate Production Systems
|
||||||
|
</Span> */}
|
||||||
<H1 className="leading-snug">About Me</H1>
|
<H1 className="leading-snug">About Me</H1>
|
||||||
<Span>
|
<Span>
|
||||||
I'm a man of few words. My{" "}
|
I'm Benjamin Toby, a DevOps/Platform engineer and CTO at
|
||||||
<Link href="/skills">Skills</Link> and{" "}
|
Summit Lending. I build and operate production systems that
|
||||||
<Link href="/work">Work</Link> speak for themselves.
|
support real businesses.
|
||||||
</Span>
|
</Span>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<H2>What I Do</H2>
|
||||||
|
<Span>I focus on infrastructure and reliability:</Span>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
<li>CI/CD automation and deployment pipelines</li>
|
||||||
|
<li>Multi-cloud architecture (AWS, GCP, Azure, Hetzner)</li>
|
||||||
|
<li>NGINX hardening, TLS, caching, rate limiting</li>
|
||||||
|
<li>Full-stack delivery with Next.js, Bun, and SQL</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<H2>Background</H2>
|
||||||
|
<P>
|
||||||
|
I started as a full-stack developer and moved into DevOps
|
||||||
|
and platform engineering out of necessity, building systems
|
||||||
|
that could scale without breaking. That led to Summit
|
||||||
|
Lending, where I manage all engineering, from infrastructure
|
||||||
|
to product.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
Along the way I built TurboCI, a deployment orchestration
|
||||||
|
tool, and Datasquirel, a cloud SQL platform. I write about
|
||||||
|
what I learn on my <a href="/blog">blog</a>.
|
||||||
|
</P>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Span>
|
||||||
|
I focus on measurable outcomes: faster deployments, lower
|
||||||
|
costs, fewer incidents.
|
||||||
|
</Span>
|
||||||
|
|
||||||
|
<Row className="items-stretch flex-col md:flex-row w-full md:w-auto gap-4">
|
||||||
|
<Button
|
||||||
|
title="View My Work"
|
||||||
|
beforeIcon={
|
||||||
|
<Terminal size={17} className="font-normal" />
|
||||||
|
}
|
||||||
|
href="/work"
|
||||||
|
className="grow w-full bg-transparent!"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
View My Work
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default async function BookCall() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,14 +12,19 @@ export default function Main() {
|
|||||||
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
<Stack className="w-full max-w-full xl:max-w-[50vw]">
|
||||||
<H1 className="leading-snug">Contact Me</H1>
|
<H1 className="leading-snug">Contact Me</H1>
|
||||||
<Span>
|
<Span>
|
||||||
Have a great idea? Want to collaborate? Let's make it
|
Need help with CI/CD, cost optimization, or production
|
||||||
happen.
|
reliability? Book a call or send a message.
|
||||||
</Span>
|
</Span>
|
||||||
|
|
||||||
<Link href="mailto:ben@tben.me" className="border-none">
|
<Link
|
||||||
|
href={`mailto:${process.env.NEXT_PUBLIC_EMAIL_ADDRESS}`}
|
||||||
|
className="border-none"
|
||||||
|
>
|
||||||
<Row className="items-center">
|
<Row className="items-center">
|
||||||
<Mail size={20} className="mt-1" />
|
<Mail size={20} className="mt-1" />
|
||||||
<Span className="text-2xl">ben@tben.me</Span>
|
<Span className="text-2xl">
|
||||||
|
{process.env.NEXT_PUBLIC_EMAIL_ADDRESS}
|
||||||
|
</Span>
|
||||||
</Row>
|
</Row>
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const SchemaJSON = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": "https://tben.me/#organization",
|
||||||
|
name: "Tben INC",
|
||||||
|
url: "https://tben.me/",
|
||||||
|
logo: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"@id": "https://tben.me/#logo",
|
||||||
|
url: "https://tben.me/logo.png",
|
||||||
|
},
|
||||||
|
sameAs: ["https://www.linkedin.com/in/benjamin-toby/"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "WebSite",
|
||||||
|
"@id": "https://tben.me/#website",
|
||||||
|
url: "https://tben.me/",
|
||||||
|
name: "Tben INC",
|
||||||
|
publisher: {
|
||||||
|
"@id": "https://tben.me/#organization",
|
||||||
|
},
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: {
|
||||||
|
"@type": "EntryPoint",
|
||||||
|
urlTemplate:
|
||||||
|
"https://tben.me/search?q={search_term_string}",
|
||||||
|
},
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"@id": "https://tben.me/#benjamin-toby",
|
||||||
|
name: "Benjamin Toby",
|
||||||
|
url: "https://tben.me/",
|
||||||
|
email: "mailto:ben@tben.me",
|
||||||
|
sameAs: [
|
||||||
|
"https://www.linkedin.com/in/benjamin-toby/",
|
||||||
|
"https://git.tben.me/tben",
|
||||||
|
],
|
||||||
|
worksFor: {
|
||||||
|
"@id": "https://tben.me/#organization",
|
||||||
|
},
|
||||||
|
jobTitle: "Founder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ export default function Header({ menuOpen, setMenuOpen }: Props) {
|
|||||||
className={twMerge(
|
className={twMerge(
|
||||||
"h-[var(--header-height)] border-0 border-b border-white/10",
|
"h-[var(--header-height)] border-0 border-b border-white/10",
|
||||||
"w-full flex flex-row items-center px-6 sticky top-0",
|
"w-full flex flex-row items-center px-6 sticky top-0",
|
||||||
"bg-[var(--bg-color)] z-10"
|
"bg-background-dark z-10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Row className="gap-6 ml-auto hidden md:flex">
|
<Row className="gap-6 ml-auto hidden md:flex">
|
||||||
|
|||||||
+23
-2
@@ -4,14 +4,35 @@ import Header from "./Header";
|
|||||||
import Footer from "./Footer";
|
import Footer from "./Footer";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import MobileMenu from "./(sections)/MobileMenu";
|
import MobileMenu from "./(sections)/MobileMenu";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { SchemaJSON } from "./(data)/shcema";
|
||||||
|
|
||||||
type Props = PropsWithChildren & {};
|
type Props = PropsWithChildren & {
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function Layout({ children }: Props) {
|
export default function Layout({ children, meta }: Props) {
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-stretch w-full min-h-screen">
|
<div className="flex flex-row items-stretch w-full min-h-screen">
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{meta?.title || "10X Software/Devops Engineer | Tben.me"}
|
||||||
|
</title>
|
||||||
|
{meta?.description && (
|
||||||
|
<meta name="description" content={meta?.description} />
|
||||||
|
)}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(SchemaJSON),
|
||||||
|
}}
|
||||||
|
></script>
|
||||||
|
</Head>
|
||||||
<Aside />
|
<Aside />
|
||||||
<div className={twMerge("flex flex-col items-start gap-0", "grow")}>
|
<div className={twMerge("flex flex-col items-start gap-0", "grow")}>
|
||||||
<Header {...{ menuOpen, setMenuOpen }} />
|
<Header {...{ menuOpen, setMenuOpen }} />
|
||||||
|
|||||||
+3
-1
@@ -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",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import Main from "@/components/pages/about";
|
|||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout meta={{ title: "About Me | Tben.me" }}>
|
||||||
<Main />
|
<Main />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -27,6 +33,9 @@ export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
|
|||||||
slug: {
|
slug: {
|
||||||
value: ctx.params?.slug,
|
value: ctx.params?.slug,
|
||||||
},
|
},
|
||||||
|
published: {
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -64,10 +73,17 @@ 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: {
|
||||||
|
published: {
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blogPosts = blogPostRes.payload;
|
const blogPosts = blogPostRes.payload;
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -23,6 +24,11 @@ export const getStaticProps: GetStaticProps<PagePropsType> = async (ctx) => {
|
|||||||
field: "id",
|
field: "id",
|
||||||
strategy: "DESC",
|
strategy: "DESC",
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
published: {
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import Main from "@/components/pages/contact";
|
|||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout meta={{ title: "Contact Me | Tben.me" }}>
|
||||||
<Main />
|
<Main />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
+9
-2
@@ -1,14 +1,19 @@
|
|||||||
import Layout from "@/layouts/main";
|
import Layout from "@/layouts/main";
|
||||||
import H1 from "@/components/lib/layout/H1";
|
|
||||||
import Main from "@/components/pages/Home";
|
import Main from "@/components/pages/Home";
|
||||||
import AboutSection from "@/components/pages/Home/(sections)/AboutSection";
|
import AboutSection from "@/components/pages/Home/(sections)/AboutSection";
|
||||||
import Divider from "@/components/lib/layout/Divider";
|
import Divider from "@/components/lib/layout/Divider";
|
||||||
import MySkillsSection from "@/components/pages/Home/(sections)/MySkillsSection";
|
import MySkillsSection from "@/components/pages/Home/(sections)/MySkillsSection";
|
||||||
import MyWorkSection from "@/components/pages/Home/(sections)/MyWorkSection";
|
import MyWorkSection from "@/components/pages/Home/(sections)/MyWorkSection";
|
||||||
|
import FooterCTASection from "@/components/pages/Home/(sections)/footer-cta-section";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout
|
||||||
|
meta={{
|
||||||
|
description:
|
||||||
|
"Software Engineer, DevOps Engineer, Full Stack Developer, Software Architect, Philosopher, Solar Energy Enthusiast.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Main />
|
<Main />
|
||||||
<Divider />
|
<Divider />
|
||||||
<AboutSection />
|
<AboutSection />
|
||||||
@@ -16,6 +21,8 @@ export default function Home() {
|
|||||||
<MySkillsSection />
|
<MySkillsSection />
|
||||||
<Divider />
|
<Divider />
|
||||||
<MyWorkSection />
|
<MyWorkSection />
|
||||||
|
<Divider />
|
||||||
|
<FooterCTASection />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import MySkillsSection from "@/components/pages/Home/(sections)/MySkillsSection"
|
|||||||
|
|
||||||
export default function SkillsPage() {
|
export default function SkillsPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout meta={{ title: "My Skills | Tben.me" }}>
|
||||||
<Main />
|
<Main />
|
||||||
<Divider />
|
<Divider />
|
||||||
<MySkillsSection noTitle expand />
|
<MySkillsSection noTitle expand />
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import MyWorkSection from "@/components/pages/Home/(sections)/MyWorkSection";
|
|||||||
|
|
||||||
export default function WorkPage() {
|
export default function WorkPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout meta={{ title: "My Work | Tben.me" }}>
|
||||||
<Main />
|
<Main />
|
||||||
<Divider />
|
<Divider />
|
||||||
<MyWorkSection noTitle expand />
|
<MyWorkSection noTitle expand />
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# https://www.tben.me robots.txt
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemaps
|
||||||
|
Sitemap: https://www.tben.me/sitemap.xml
|
||||||
|
|
||||||
|
# Optional: disallow common non-content paths (uncomment if needed)
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /_next/
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<!-- Homepage -->
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- Main Pages -->
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/about</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/skills</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/work</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/blog</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/contact</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<!-- Blog Posts -->
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/blog/nginx-reverse-proxy-caching-rate-limiting-static-files</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/blog/solving-the-database-hassle</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/blog/find-your-perfect-framework</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://www.tben.me/blog/choosing-your-tech-stack</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
const articles_objects = [];
|
||||||
|
|
||||||
|
const substack_href = "https://substack.com/@benjamintoby";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
async function sleep(wait) {
|
||||||
|
return new Promise((res) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
res(true);
|
||||||
|
}, wait);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function grabContentHeight() {
|
||||||
|
return document.querySelector(
|
||||||
|
"div[style='max-width: 568px;']",
|
||||||
|
// @ts-ignore
|
||||||
|
)?.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToEnd() {
|
||||||
|
let last_content_height = grabContentHeight();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
window.scrollTo({
|
||||||
|
top: document.body.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
const current_content_height = grabContentHeight();
|
||||||
|
|
||||||
|
if (current_content_height > last_content_height) {
|
||||||
|
last_content_height = current_content_height;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await scrollToEnd();
|
||||||
|
|
||||||
|
const articles = Array.from(
|
||||||
|
document.querySelectorAll("div[role='article']"),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Handling ${articles.length} Articles ...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < articles.length; i++) {
|
||||||
|
let present_articles = Array.from(
|
||||||
|
document.querySelectorAll("div[role='article']"),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${present_articles.length} Present Articles!`);
|
||||||
|
|
||||||
|
while (i > present_articles.length - 1) {
|
||||||
|
window.scrollTo({
|
||||||
|
top: document.body.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Searching for Article #${i} ...`);
|
||||||
|
|
||||||
|
await sleep(5000);
|
||||||
|
|
||||||
|
present_articles = Array.from(
|
||||||
|
document.querySelectorAll("div[role='article']"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = present_articles[i];
|
||||||
|
|
||||||
|
console.log(`Handling Article #${i} ...`);
|
||||||
|
|
||||||
|
const content_div = article.querySelector(`.FeedProseMirror`);
|
||||||
|
const date_link = Array.from(article.querySelectorAll("a")).find((a) =>
|
||||||
|
Boolean(a.getAttribute("title")),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!content_div) continue;
|
||||||
|
|
||||||
|
const content_div_first_paragraph = content_div.querySelector(`p`);
|
||||||
|
|
||||||
|
if (!content_div_first_paragraph) continue;
|
||||||
|
|
||||||
|
let window_url = window.location.href;
|
||||||
|
const initial_text_content = content_div.textContent;
|
||||||
|
|
||||||
|
const article_object = {
|
||||||
|
title: initial_text_content,
|
||||||
|
content: initial_text_content,
|
||||||
|
html: content_div.innerHTML,
|
||||||
|
images: [],
|
||||||
|
date: date_link?.getAttribute("title"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const article_images = Array.from(
|
||||||
|
article.querySelectorAll("picture img"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (article_images?.[0]) {
|
||||||
|
for (let img = 0; img < article_images.length; img++) {
|
||||||
|
if (img > 0) {
|
||||||
|
const article_image = article_images[img];
|
||||||
|
// @ts-ignore
|
||||||
|
const article_image_srcset = article_image.srcset;
|
||||||
|
const largest_image = article_image_srcset
|
||||||
|
.split(` `)
|
||||||
|
.at(-2);
|
||||||
|
// @ts-ignore
|
||||||
|
article_object.images.push(largest_image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const more_content = Array.from(article.querySelectorAll("a"))
|
||||||
|
.find((el) => el.textContent.includes("See more"))
|
||||||
|
?.click();
|
||||||
|
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
let new_window_url = window.location.href;
|
||||||
|
|
||||||
|
if (new_window_url === window_url) {
|
||||||
|
const new_article_content =
|
||||||
|
article.querySelector(`.FeedProseMirror`);
|
||||||
|
|
||||||
|
if (new_article_content) {
|
||||||
|
article_object.content = new_article_content.textContent;
|
||||||
|
article_object.html = new_article_content.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
articles_objects.push(article_object);
|
||||||
|
} else {
|
||||||
|
const text_sample = content_div_first_paragraph.textContent;
|
||||||
|
const target_content_div = Array.from(
|
||||||
|
document.querySelectorAll(".ProseMirror.FeedProseMirror"),
|
||||||
|
).find((el) => el.textContent.includes(text_sample));
|
||||||
|
|
||||||
|
if (target_content_div) {
|
||||||
|
article_object.content = target_content_div.textContent;
|
||||||
|
article_object.html = target_content_div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
articles_objects.push(article_object);
|
||||||
|
|
||||||
|
window.history.back();
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(articles_objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+10
-2
@@ -8,7 +8,7 @@
|
|||||||
--color-primary: #02030f;
|
--color-primary: #02030f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twui-button-general {
|
.twui-button-general:not(.twui-breadcrumbs-back-button) {
|
||||||
@apply rounded-none bg-white text-black;
|
@apply rounded-none bg-white text-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +60,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.twui-a {
|
.twui-a {
|
||||||
@apply !text-foreground-light/70 dark:!text-foreground-dark/70 border-none;
|
@apply !text-foreground-light/70 dark:!text-foreground-dark/70 border-none hover:dark:!text-white hover:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.twui-a.active {
|
.twui-a.active {
|
||||||
@apply !text-foreground-light dark:!text-foreground-dark font-bold;
|
@apply !text-foreground-light dark:!text-foreground-dark font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dotted-text {
|
||||||
|
@apply border-0 border-b-2 border-dotted border-foreground-light/20 dark:!border-foreground-dark/20 pb-[2px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotted-text:hover {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./layouts/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: "var(--background)",
|
|
||||||
foreground: "var(--foreground)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
darkMode: "class",
|
|
||||||
} satisfies Config;
|
|
||||||
@@ -1,22 +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;
|
|
||||||
date_created?: string;
|
|
||||||
date_created_code?: number;
|
|
||||||
date_created_timestamp?: string;
|
|
||||||
date_updated?: string;
|
|
||||||
date_updated_code?: number;
|
|
||||||
date_updated_timestamp?: string;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user