Updates
This commit is contained in:
parent
998158369a
commit
5587024789
79
components/lib/editors/TinyMCE/index.tsx
Normal file
79
components/lib/editors/TinyMCE/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { RawEditorOptions, TinyMCE, Editor } from "./tinymce";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TinyMCEEditorProps = {
|
||||
tinyMCE?: TinyMCE | null;
|
||||
options?: RawEditorOptions;
|
||||
editorRef?: React.MutableRefObject<Editor | null>;
|
||||
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
|
||||
wrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
let interval: any;
|
||||
|
||||
/**
|
||||
* # Tiny MCE Editor Component
|
||||
* @className_wrapper twui-rte-wrapper
|
||||
*/
|
||||
export default function TinyMCEEditor({
|
||||
options,
|
||||
editorRef,
|
||||
setEditor,
|
||||
tinyMCE,
|
||||
wrapperProps,
|
||||
defaultValue,
|
||||
}: TinyMCEEditorProps) {
|
||||
const editorComponentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const FINAL_HEIGHT = options?.height || 500;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!editorComponentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
tinyMCE?.init({
|
||||
height: FINAL_HEIGHT,
|
||||
menubar: false,
|
||||
plugins: [
|
||||
"advlist lists link image charmap print preview anchor",
|
||||
"searchreplace visualblocks code fullscreen",
|
||||
"insertdatetime media table paste code help wordcount",
|
||||
],
|
||||
toolbar:
|
||||
"undo redo | blocks | bold italic | bullist numlist outdent indent | removeformat",
|
||||
content_style:
|
||||
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px }",
|
||||
init_instance_callback: (editor) => {
|
||||
setEditor?.(editor as any);
|
||||
if (editorRef) editorRef.current = editor as any;
|
||||
if (defaultValue) editor.setContent(defaultValue);
|
||||
},
|
||||
base_url: "https://datasquirel.com/tinymce-public",
|
||||
body_class: "twui-tinymce",
|
||||
...options,
|
||||
license_key: "gpl",
|
||||
target: editorComponentRef.current,
|
||||
});
|
||||
}, [tinyMCE]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
ref={editorComponentRef}
|
||||
style={{
|
||||
height: FINAL_HEIGHT + "px",
|
||||
...wrapperProps?.style,
|
||||
}}
|
||||
className={twMerge(
|
||||
"bg-slate-200 dark:bg-slate-700 rounded-sm",
|
||||
"twui-rte-wrapper"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
3313
components/lib/editors/TinyMCE/tinymce.d.ts
vendored
Normal file
3313
components/lib/editors/TinyMCE/tinymce.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
34
components/lib/editors/TinyMCE/useTinyMCE.tsx
Normal file
34
components/lib/editors/TinyMCE/useTinyMCE.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { TinyMCE } from "./tinymce";
|
||||
|
||||
let interval: any;
|
||||
|
||||
export default function useTinyMCE() {
|
||||
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// @ts-ignore
|
||||
if (window.tinymce) {
|
||||
console.log("Tinymce already exists");
|
||||
// @ts-ignore
|
||||
setTinyMCE(window.tinymce);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://datasquirel.com/tinymce-public/tinymce.min.js";
|
||||
script.async = true;
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
// @ts-ignore
|
||||
if (window.tinymce) {
|
||||
// @ts-ignore
|
||||
setTinyMCE(window.tinymce);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { tinyMCE };
|
||||
}
|
@ -18,7 +18,7 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border rounded",
|
||||
"border-slate-300 dark:border-white/20",
|
||||
"border-slate-300 dark:border-white/10",
|
||||
spacing
|
||||
? spacing == "normal"
|
||||
? "px-3 py-2"
|
||||
|
@ -23,7 +23,7 @@ export default function Breadcrumbs() {
|
||||
});
|
||||
|
||||
pathLinks.forEach((linkText, index, array) => {
|
||||
if (!linkText?.match(/./) || index == 1) {
|
||||
if (!linkText?.match(/./)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ export default function Breadcrumbs() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="gap-4">
|
||||
<Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
|
||||
{links.map((linkObject, index, array) => {
|
||||
if (index === links.length - 1) {
|
||||
return (
|
||||
|
@ -3,7 +3,11 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* # General Card
|
||||
* @className_wrapper twui-card
|
||||
* @className twui-card
|
||||
* @className twui-card-link
|
||||
*
|
||||
* @info use the classname `nested-link` to prevent the card from being clickable when
|
||||
* a link (or the target element with this calss) inside the card is clicked.
|
||||
*/
|
||||
export default function Card({
|
||||
href,
|
||||
@ -24,7 +28,9 @@ export default function Card({
|
||||
className={twMerge(
|
||||
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
|
||||
"border border-slate-200 dark:border-white/10 border-solid",
|
||||
href ? "hover:bg-slate-100 dark:hover:bg-white/30" : "",
|
||||
href
|
||||
? "hover:bg-slate-100 dark:hover:bg-white/30 hover:border-slate-400 dark:hover:border-white/20"
|
||||
: "",
|
||||
"twui-card",
|
||||
props.className
|
||||
)}
|
||||
@ -35,7 +41,26 @@ export default function Card({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} {...linkProps}>
|
||||
<a
|
||||
href={href}
|
||||
{...linkProps}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
if (targetEl.closest(".nested-link")) {
|
||||
e.preventDefault();
|
||||
} else if (e.ctrlKey) {
|
||||
window.open(href, "_blank");
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
linkProps?.onClick?.(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"cursor-pointer",
|
||||
"twui-card-link",
|
||||
linkProps?.className
|
||||
)}
|
||||
>
|
||||
{component}
|
||||
</a>
|
||||
);
|
||||
|
146
components/lib/elements/Dropdown.tsx
Normal file
146
components/lib/elements/Dropdown.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const TWUIDropdownContentPositions = [
|
||||
"left",
|
||||
"right",
|
||||
"center",
|
||||
] as const;
|
||||
|
||||
export type TWUI_DROPDOWN_PROPS = PropsWithChildren &
|
||||
DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
target: React.ReactNode;
|
||||
contentWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
targetWrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
debounce?: number;
|
||||
hoverOpen?: boolean;
|
||||
position?: (typeof TWUIDropdownContentPositions)[number];
|
||||
topOffset?: number;
|
||||
externalSetOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Toggle Component
|
||||
* @className_wrapper twui-dropdown-wrapper
|
||||
* @className_wrapper twui-dropdown-target
|
||||
* @className_wrapper twui-dropdown-content
|
||||
*/
|
||||
export default function Dropdown({
|
||||
contentWrapperProps,
|
||||
targetWrapperProps,
|
||||
hoverOpen,
|
||||
debounce = 500,
|
||||
target,
|
||||
position = "center",
|
||||
topOffset,
|
||||
externalSetOpen,
|
||||
...props
|
||||
}: TWUI_DROPDOWN_PROPS) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = React.useCallback((e: MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const closestWrapper = targetEl.closest(".twui-dropdown-wrapper");
|
||||
|
||||
if (!closestWrapper) {
|
||||
externalSetOpen?.(false);
|
||||
return setOpen(false);
|
||||
}
|
||||
if (closestWrapper && closestWrapper !== dropdownRef.current) {
|
||||
externalSetOpen?.(false);
|
||||
return setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-col items-center relative",
|
||||
"twui-dropdown-wrapper",
|
||||
props.className
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
externalSetOpen?.(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!hoverOpen) return;
|
||||
timeout = setTimeout(() => {
|
||||
externalSetOpen?.(false);
|
||||
setOpen(false);
|
||||
}, debounce);
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
externalSetOpen?.(!open);
|
||||
setOpen(!open);
|
||||
}}
|
||||
className={twMerge(
|
||||
"cursor-pointer",
|
||||
"twui-dropdown-target",
|
||||
targetWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...contentWrapperProps}
|
||||
className={twMerge(
|
||||
"absolute z-10",
|
||||
position == "left"
|
||||
? "left-0"
|
||||
: position == "right"
|
||||
? "right-0"
|
||||
: "",
|
||||
open ? "flex" : "hidden",
|
||||
"twui-dropdown-content",
|
||||
contentWrapperProps?.className
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!hoverOpen) return;
|
||||
window.clearTimeout(timeout);
|
||||
}}
|
||||
style={{
|
||||
top: `calc(100% + ${topOffset || 0}px)`,
|
||||
...contentWrapperProps?.style,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -8,6 +8,7 @@ type Props = DetailedHTMLProps<
|
||||
HTMLDivElement
|
||||
> & {
|
||||
target: React.ReactNode;
|
||||
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -15,11 +16,12 @@ type Props = DetailedHTMLProps<
|
||||
* @className_wrapper twui-modal-root
|
||||
* @className_wrapper twui-modal
|
||||
*/
|
||||
export default function Modal({ target, ...props }: Props) {
|
||||
export default function Modal({ target, targetRef, ...props }: Props) {
|
||||
const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrapperEl = document.createElement("div");
|
||||
|
||||
wrapperEl.className = twMerge(
|
||||
"fixed z-[200000] top-0 left-0 w-screen h-screen",
|
||||
"flex flex-col items-center justify-center",
|
||||
@ -57,6 +59,7 @@ export default function Modal({ target, ...props }: Props) {
|
||||
const root = createRoot(wrapper);
|
||||
root.render(modalEl);
|
||||
}}
|
||||
ref={targetRef as any}
|
||||
>
|
||||
{target}
|
||||
</div>
|
||||
|
@ -20,7 +20,7 @@ export default function Paper({
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
|
||||
"flex flex-col items-start p-4 rounded bg-white dark:bg-white/10 gap-4",
|
||||
"border border-slate-200 dark:border-white/10 border-solid w-full",
|
||||
"twui-paper",
|
||||
props.className
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Input from "../form/Input";
|
||||
import Input, { InputProps } from "../form/Input";
|
||||
import Button from "../layout/Button";
|
||||
import Row from "../layout/Row";
|
||||
import { Search as SearchIcon } from "lucide-react";
|
||||
@ -11,20 +11,13 @@ import React, {
|
||||
|
||||
let timeout: any;
|
||||
|
||||
export type SearchProps = DetailedHTMLProps<
|
||||
export type SearchProps<KeyType extends string> = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
dispatch?: (value?: string) => void;
|
||||
delay?: number;
|
||||
inputProps?: DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> &
|
||||
DetailedHTMLProps<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
HTMLTextAreaElement
|
||||
>;
|
||||
inputProps?: InputProps<KeyType>;
|
||||
buttonProps?: DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
@ -37,13 +30,13 @@ export type SearchProps = DetailedHTMLProps<
|
||||
* @className_circle twui-search-input
|
||||
* @className_circle twui-search-button
|
||||
*/
|
||||
export default function Search({
|
||||
export default function Search<KeyType extends string>({
|
||||
dispatch,
|
||||
delay = 500,
|
||||
inputProps,
|
||||
buttonProps,
|
||||
...props
|
||||
}: SearchProps) {
|
||||
}: SearchProps<KeyType>) {
|
||||
const [input, setInput] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
|
164
components/lib/elements/StarRating.tsx
Normal file
164
components/lib/elements/StarRating.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { LucideProps, Star } from "lucide-react";
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
ForwardRefExoticComponent,
|
||||
HTMLAttributes,
|
||||
RefAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type StarProps = {
|
||||
total?: number;
|
||||
value?: number;
|
||||
size?: number;
|
||||
starProps?: LucideProps;
|
||||
allowRating?: boolean;
|
||||
setValueExternal?: React.Dispatch<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
export type TWUI_STAR_RATING_PROPS = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> &
|
||||
StarProps;
|
||||
|
||||
let timeout: any;
|
||||
|
||||
/**
|
||||
* # Star Rating Component
|
||||
* @className_wrapper twui-star-rating
|
||||
*/
|
||||
export default function StarRating({
|
||||
total = 5,
|
||||
value = 0,
|
||||
size,
|
||||
starProps,
|
||||
allowRating,
|
||||
setValueExternal,
|
||||
...props
|
||||
}: TWUI_STAR_RATING_PROPS) {
|
||||
const totalArray = Array(total).fill(null);
|
||||
|
||||
const [finalValue, setFinalValue] = React.useState(value);
|
||||
const [selectedStarValue, setSelectedStarValue] = React.useState(value);
|
||||
|
||||
const starClicked = React.useRef(false);
|
||||
const sectionHovered = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
setValueExternal?.(finalValue);
|
||||
}, 500);
|
||||
}, [selectedStarValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center gap-0 -ml-[2px]",
|
||||
"twui-star-rating",
|
||||
props.className
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
sectionHovered.current = true;
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
sectionHovered.current = false;
|
||||
}}
|
||||
>
|
||||
{totalArray.map((_, index) => {
|
||||
return (
|
||||
<StarComponent
|
||||
{...{
|
||||
total,
|
||||
value,
|
||||
size,
|
||||
starProps,
|
||||
index,
|
||||
allowRating,
|
||||
finalValue,
|
||||
setFinalValue,
|
||||
starClicked,
|
||||
selectedStarValue,
|
||||
sectionHovered,
|
||||
setSelectedStarValue,
|
||||
}}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StarComponent({
|
||||
value = 0,
|
||||
size = 20,
|
||||
starProps,
|
||||
index,
|
||||
allowRating,
|
||||
finalValue,
|
||||
setFinalValue,
|
||||
starClicked,
|
||||
sectionHovered,
|
||||
setSelectedStarValue,
|
||||
selectedStarValue,
|
||||
}: StarProps & {
|
||||
index: number;
|
||||
finalValue: number;
|
||||
setFinalValue: React.Dispatch<React.SetStateAction<number>>;
|
||||
setSelectedStarValue: React.Dispatch<React.SetStateAction<number>>;
|
||||
starClicked: React.MutableRefObject<boolean>;
|
||||
sectionHovered: React.MutableRefObject<boolean>;
|
||||
selectedStarValue: number;
|
||||
}) {
|
||||
const isActive = index < finalValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge("p-[2px]", allowRating && "cursor-pointer")}
|
||||
onMouseEnter={() => {
|
||||
if (!allowRating) return;
|
||||
|
||||
setFinalValue(index + 1);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!allowRating) return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (sectionHovered.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!starClicked.current) {
|
||||
setFinalValue(0);
|
||||
}
|
||||
|
||||
if (selectedStarValue) {
|
||||
setFinalValue(selectedStarValue);
|
||||
}
|
||||
}, 200);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!allowRating) return;
|
||||
|
||||
starClicked.current = true;
|
||||
setSelectedStarValue(index + 1);
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={size}
|
||||
className={twMerge(
|
||||
"text-slate-300 dark:text-white/20",
|
||||
isActive &&
|
||||
"text-orange-500 dark:text-orange-400 fill-orange-500 dark:fill-orange-400",
|
||||
// allowRating &&
|
||||
// "hover:text-orange-500 hover:dark:text-orange-400 hover:fill-orange-500 hover:dark:fill-orange-400",
|
||||
starProps?.className
|
||||
)}
|
||||
{...starProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
108
components/lib/elements/Tabs.tsx
Normal file
108
components/lib/elements/Tabs.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Border from "./Border";
|
||||
import Stack from "../layout/Stack";
|
||||
import Row from "../layout/Row";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
content: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = React.ComponentProps<typeof Stack> & {
|
||||
tabsContentArray: TWUITabsObject[];
|
||||
tabsBorderProps?: React.ComponentProps<typeof Border>;
|
||||
tabsButtonsWrapperProps?: React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
centered?: boolean;
|
||||
debounce?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tabs Component
|
||||
* @className twui-tabs-wrapper
|
||||
* @className twui-tab-buttons
|
||||
* @className twui-tab-button-active
|
||||
* @className twui-tab-buttons-wrapper
|
||||
*/
|
||||
export default function Tabs({
|
||||
tabsContentArray,
|
||||
tabsBorderProps,
|
||||
tabsButtonsWrapperProps,
|
||||
centered,
|
||||
debounce = 100,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
const values = tabsContentArray.map((obj) => obj.value);
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState(
|
||||
tabsContentArray.find((ctn) => ctn.defaultActive)?.value ||
|
||||
values[0] ||
|
||||
undefined
|
||||
);
|
||||
|
||||
const targetContent = tabsContentArray.find(
|
||||
(ctn) => ctn.value == activeValue
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
{...props}
|
||||
className={twMerge("w-full", "twui-tabs-wrapper", props.className)}
|
||||
>
|
||||
<div
|
||||
{...tabsButtonsWrapperProps}
|
||||
className={twMerge(
|
||||
"w-full",
|
||||
"twui-tab-buttons-wrapper",
|
||||
tabsButtonsWrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<Border className="p-0 w-full" {...tabsBorderProps}>
|
||||
<Row
|
||||
className={twMerge(
|
||||
"gap-0 items-stretch w-full",
|
||||
centered && "justify-center"
|
||||
)}
|
||||
>
|
||||
{values.map((value, index) => {
|
||||
const targetObject = tabsContentArray.find(
|
||||
(ctn) => ctn.value == value
|
||||
);
|
||||
|
||||
const isActive = value == activeValue;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={twMerge(
|
||||
"px-6 py-2 rounded -ml-[1px]",
|
||||
isActive
|
||||
? "bg-blue-500 text-white outline-none twui-tab-button-active"
|
||||
: "text-slate-400 dark:text-white/40 hover:text-slate-800 dark:hover:text-white" +
|
||||
" cursor-pointer",
|
||||
"twui-tab-buttons"
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveValue(undefined);
|
||||
setTimeout(() => {
|
||||
setActiveValue(value);
|
||||
}, debounce);
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{targetObject?.title}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Border>
|
||||
</div>
|
||||
{targetContent?.content}
|
||||
</Stack>
|
||||
);
|
||||
}
|
75
components/lib/elements/Tag.tsx
Normal file
75
components/lib/elements/Tag.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUITabsObject = {
|
||||
title: string;
|
||||
value: string;
|
||||
content: React.ReactNode;
|
||||
defaultActive?: boolean;
|
||||
};
|
||||
|
||||
export type TWUI_TOGGLE_PROPS = PropsWithChildren &
|
||||
React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
color?: "normal" | "secondary" | "error" | "success" | "gray";
|
||||
variant?: "normal" | "outlined" | "ghost";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Tabs Component
|
||||
* @className twui-tag
|
||||
*/
|
||||
export default function Tag({
|
||||
color,
|
||||
variant,
|
||||
children,
|
||||
...props
|
||||
}: TWUI_TOGGLE_PROPS) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xs px-2 py-0.5 rounded-full outline outline-0",
|
||||
color == "secondary"
|
||||
? "bg-violet-600 outline-violet-600"
|
||||
: color == "success"
|
||||
? "bg-emerald-700 outline-emerald-700"
|
||||
: color == "error"
|
||||
? "bg-orange-700 outline-orange-700"
|
||||
: color == "gray"
|
||||
? "bg-slate-100 outline-slate-200 dark:bg-white/10 dark:outline-white/20 text-slate-500 dark:text-white"
|
||||
: "bg-blue-600 outline-blue-600",
|
||||
variant == "outlined"
|
||||
? "!bg-transparent outline-1 " +
|
||||
(color == "secondary"
|
||||
? "text-violet-600"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: variant == "ghost"
|
||||
? "!bg-transparent outline-none border-none " +
|
||||
(color == "secondary"
|
||||
? "text-violet-600"
|
||||
: color == "success"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: color == "error"
|
||||
? "text-orange-700"
|
||||
: color == "gray"
|
||||
? "text-slate-700 dark:text-white/80"
|
||||
: "text-blue-600")
|
||||
: "",
|
||||
|
||||
"twui-tag",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
100
components/lib/elements/Toast.tsx
Normal file
100
components/lib/elements/Toast.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import Card from "./Card";
|
||||
import { X } from "lucide-react";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export const ToastStyles = ["normal", "success", "error"] as const;
|
||||
export const ToastColors = ToastStyles;
|
||||
|
||||
type Props = DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
open?: boolean;
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeDelay?: number;
|
||||
color?: (typeof ToastStyles)[number];
|
||||
};
|
||||
|
||||
/**
|
||||
* # Toast Component
|
||||
* @className twui-toast-root
|
||||
* @className twui-toast
|
||||
* @className twui-toast-success
|
||||
* @className twui-toast-error
|
||||
*/
|
||||
export default function Toast({
|
||||
open,
|
||||
setOpen,
|
||||
closeDelay = 4000,
|
||||
color,
|
||||
...props
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const toastEl = (
|
||||
<Card
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"pl-6 pr-8 py-4 bg-blue-700 dark:bg-blue-800",
|
||||
color == "success"
|
||||
? "bg-emerald-600 dark:bg-emerald-700 twui-toast-success"
|
||||
: color == "error"
|
||||
? "bg-orange-600 dark:bg-orange-700 twui-toast-error"
|
||||
: "",
|
||||
props.className,
|
||||
"twui-toast"
|
||||
)}
|
||||
>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"absolute top-2 right-2 z-[100] cursor-pointer"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const rootWrapperEl = targetEl.closest(".twui-toast-root");
|
||||
|
||||
if (rootWrapperEl) {
|
||||
rootWrapperEl.parentElement?.removeChild(rootWrapperEl);
|
||||
setOpen?.(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</Span>
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrapperEl = document.createElement("div");
|
||||
|
||||
wrapperEl.className = twMerge(
|
||||
"fixed z-[200000] bottom-10 right-10",
|
||||
"flex flex-col items-center justify-center",
|
||||
"twui-toast-root"
|
||||
);
|
||||
|
||||
document.body.appendChild(wrapperEl);
|
||||
const root = createRoot(wrapperEl);
|
||||
root.render(toastEl);
|
||||
|
||||
setTimeout(() => {
|
||||
closeToast({ wrapperEl });
|
||||
setOpen?.(false);
|
||||
}, closeDelay);
|
||||
|
||||
return function () {
|
||||
closeToast({ wrapperEl });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeToast({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) {
|
||||
if (!wrapperEl) return;
|
||||
wrapperEl.parentElement?.removeChild(wrapperEl);
|
||||
}
|
98
components/lib/form/Checkbox.tsx
Normal file
98
components/lib/form/Checkbox.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import CheckMarkSVG from "../svgs/CheckMarkSVG";
|
||||
|
||||
export type CheckboxProps = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> & {
|
||||
name: string;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
label?: string | ReactNode;
|
||||
labelProps?: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLLabelElement>,
|
||||
HTMLLabelElement
|
||||
>;
|
||||
defaultChecked?: boolean;
|
||||
wrapperClassName?: string;
|
||||
setChecked?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Checkbox Component
|
||||
* @className twui-checkbox
|
||||
* @className twui-checkbox-checked
|
||||
* @className twui-checkbox-unchecked
|
||||
*/
|
||||
export default function Checkbox({
|
||||
wrapperProps,
|
||||
label,
|
||||
labelProps,
|
||||
size,
|
||||
name,
|
||||
wrapperClassName,
|
||||
defaultChecked,
|
||||
setChecked,
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const finalSize = size || 20;
|
||||
|
||||
const [internalChecked, setInternalChecked] = React.useState(
|
||||
defaultChecked || false
|
||||
);
|
||||
|
||||
const checkMarkRef = React.useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...wrapperProps}
|
||||
onClick={(e) => {
|
||||
checkMarkRef.current?.click();
|
||||
wrapperProps?.onClick?.(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2",
|
||||
wrapperClassName,
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{...props}
|
||||
width={finalSize}
|
||||
height={finalSize}
|
||||
className={twMerge("hidden")}
|
||||
name={name}
|
||||
onChange={(e) => {
|
||||
setInternalChecked(e.target.checked);
|
||||
setChecked?.(e.target.checked);
|
||||
}}
|
||||
ref={checkMarkRef as any}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-center p-[3px] rounded",
|
||||
internalChecked
|
||||
? "bg-emerald-700 twui-checkbox-checked"
|
||||
: "outline-slate-600 dark:outline-white/50 outline-2 outline -outline-offset-2 twui-checkbox-unchecked",
|
||||
"twui-checkbox"
|
||||
)}
|
||||
style={{
|
||||
width: finalSize + "px",
|
||||
height: finalSize + "px",
|
||||
}}
|
||||
>
|
||||
{internalChecked && <CheckMarkSVG />}
|
||||
</div>
|
||||
{label && <label>{label}</label>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, FormHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@ -5,17 +6,28 @@ import { twMerge } from "tailwind-merge";
|
||||
* # Form Element
|
||||
* @className twui-form
|
||||
*/
|
||||
export default function Form({
|
||||
export default function Form<T extends object = { [key: string]: any }>({
|
||||
...props
|
||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>) {
|
||||
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
|
||||
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
|
||||
}) {
|
||||
const finalProps = _.omit(props, "submitHandler");
|
||||
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-stretch gap-2 w-full bg-transparent",
|
||||
"twui-form",
|
||||
props.className
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formEl = e.target as HTMLFormElement;
|
||||
const formData = new FormData(formEl);
|
||||
const data = Object.fromEntries(formData.entries()) as T;
|
||||
props.submitHandler?.(e, data);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</form>
|
||||
|
@ -1,25 +1,24 @@
|
||||
import Button from "@/components/lib/layout/Button";
|
||||
import Stack from "@/components/lib/layout/Stack";
|
||||
import Button from "../layout/Button";
|
||||
import Stack from "../layout/Stack";
|
||||
import { ImagePlus, X } from "lucide-react";
|
||||
import React, { DetailedHTMLProps } from "react";
|
||||
import Card from "@/components/lib/elements/Card";
|
||||
import Span from "@/components/lib/layout/Span";
|
||||
import Center from "@/components/lib/layout/Center";
|
||||
import Card from "../elements/Card";
|
||||
import Span from "../layout/Span";
|
||||
import Center from "../layout/Center";
|
||||
import imageInputToBase64, {
|
||||
ImageInputToBase64FunctionReturn,
|
||||
} from "../utils/form/imageInputToBase64";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type ImageUploadProps = {
|
||||
type ImageUploadProps = DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
> & {
|
||||
onChange?: (imgData: ImageInputToBase64FunctionReturn | undefined) => any;
|
||||
fileInputProps?: DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
>;
|
||||
wrapperProps?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
placeHolderWrapper?: DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
@ -32,23 +31,27 @@ type ImageUploadProps = {
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>;
|
||||
label?: string;
|
||||
disablePreview?: boolean;
|
||||
};
|
||||
|
||||
export default function ImageUpload({
|
||||
onChange,
|
||||
fileInputProps,
|
||||
wrapperProps,
|
||||
placeHolderWrapper,
|
||||
previewImageWrapperProps,
|
||||
previewImageProps,
|
||||
label,
|
||||
disablePreview,
|
||||
...props
|
||||
}: ImageUploadProps) {
|
||||
const [src, setSrc] = React.useState<string | undefined>(undefined);
|
||||
const inputRef = React.useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={twMerge("w-full", wrapperProps?.className)}
|
||||
{...wrapperProps}
|
||||
{...props}
|
||||
className={twMerge("w-full h-[300px]", props?.className)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@ -65,12 +68,21 @@ export default function ImageUpload({
|
||||
/>
|
||||
|
||||
{src ? (
|
||||
<Card className="w-full relative" {...previewImageWrapperProps}>
|
||||
<Card
|
||||
className="w-full relative h-full items-center justify-center"
|
||||
{...previewImageWrapperProps}
|
||||
>
|
||||
{disablePreview ? (
|
||||
<Span className="opacity-50" size="small">
|
||||
Image Uploaded!
|
||||
</Span>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
className="w-full h-[300px] object-contain"
|
||||
className="w-full object-contain"
|
||||
{...previewImageProps}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute p-2 top-2 right-2 z-20"
|
||||
@ -79,13 +91,13 @@ export default function ImageUpload({
|
||||
onChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
<X className="text-slate-950" />
|
||||
<X className="text-slate-950 dark:text-white" />
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
className={twMerge(
|
||||
"w-full h-[300px] 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
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@ -98,7 +110,7 @@ export default function ImageUpload({
|
||||
<Stack className="items-center gap-2">
|
||||
<ImagePlus className="text-slate-400" />
|
||||
<Span size="smaller" variant="faded">
|
||||
Click to Upload Image
|
||||
{label || "Click to Upload Image"}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
@ -6,8 +6,79 @@ import React, {
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Span from "../layout/Span";
|
||||
|
||||
export type InputProps = DetailedHTMLProps<
|
||||
let timeout: any;
|
||||
|
||||
const autocompleteOptions = [
|
||||
// Personal Information
|
||||
"name",
|
||||
"honorific-prefix",
|
||||
"given-name",
|
||||
"additional-name",
|
||||
"family-name",
|
||||
"honorific-suffix",
|
||||
"nickname",
|
||||
|
||||
// Contact Information
|
||||
"email",
|
||||
"username",
|
||||
"new-password",
|
||||
"current-password",
|
||||
"one-time-code",
|
||||
"organization-title",
|
||||
"organization",
|
||||
|
||||
// Address Fields
|
||||
"street-address",
|
||||
"address-line1",
|
||||
"address-line2",
|
||||
"address-line3",
|
||||
"address-level4",
|
||||
"address-level3",
|
||||
"address-level2",
|
||||
"address-level1",
|
||||
"country",
|
||||
"country-name",
|
||||
"postal-code",
|
||||
|
||||
// Phone Numbers
|
||||
"tel",
|
||||
"tel-country-code",
|
||||
"tel-national",
|
||||
"tel-area-code",
|
||||
"tel-local",
|
||||
"tel-extension",
|
||||
|
||||
// Dates
|
||||
"bday",
|
||||
"bday-day",
|
||||
"bday-month",
|
||||
"bday-year",
|
||||
|
||||
// Payment Information
|
||||
"cc-name",
|
||||
"cc-given-name",
|
||||
"cc-additional-name",
|
||||
"cc-family-name",
|
||||
"cc-number",
|
||||
"cc-exp",
|
||||
"cc-exp-month",
|
||||
"cc-exp-year",
|
||||
"cc-csc",
|
||||
"cc-type",
|
||||
|
||||
// Additional Options
|
||||
"sex",
|
||||
"url",
|
||||
"photo",
|
||||
|
||||
// Special Values
|
||||
"on", // Enables autofill (default)
|
||||
"off", // Disables autofill
|
||||
] as const;
|
||||
|
||||
export type InputProps<KeyType extends string> = DetailedHTMLProps<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> &
|
||||
@ -30,13 +101,21 @@ export type InputProps = DetailedHTMLProps<
|
||||
HTMLLabelElement
|
||||
>;
|
||||
componentRef?: RefObject<any>;
|
||||
validationRegex?: RegExp;
|
||||
debounce?: number;
|
||||
invalidMessage?: string;
|
||||
validationFunction?: (value: string) => Promise<boolean>;
|
||||
autoComplete?: (typeof autocompleteOptions)[number];
|
||||
name?: KeyType;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Input Element
|
||||
* @className twui-input
|
||||
* @className twui-input-wrapper
|
||||
* @className twui-input-invalid
|
||||
*/
|
||||
export default function Input({
|
||||
export default function Input<KeyType extends string>({
|
||||
label,
|
||||
variant,
|
||||
prefix,
|
||||
@ -46,9 +125,43 @@ export default function Input({
|
||||
wrapperProps,
|
||||
showLabel,
|
||||
istextarea,
|
||||
debounce,
|
||||
invalidMessage,
|
||||
autoComplete,
|
||||
validationFunction,
|
||||
validationRegex,
|
||||
...props
|
||||
}: InputProps) {
|
||||
}: InputProps<KeyType>) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
const [value, setValue] = React.useState(
|
||||
props.defaultValue ? String(props.defaultValue) : ""
|
||||
);
|
||||
|
||||
delete props.defaultValue;
|
||||
|
||||
const [isValid, setIsValid] = React.useState(true);
|
||||
|
||||
const DEFAULT_DEBOUNCE = 500;
|
||||
const finalDebounce = debounce || DEFAULT_DEBOUNCE;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!value.match(/./)) return setIsValid(true);
|
||||
window.clearTimeout(timeout);
|
||||
|
||||
if (validationRegex) {
|
||||
timeout = setTimeout(() => {
|
||||
setIsValid(validationRegex.test(value));
|
||||
}, finalDebounce);
|
||||
}
|
||||
|
||||
if (validationFunction) {
|
||||
timeout = setTimeout(() => {
|
||||
validationFunction(value).then((res) => {
|
||||
setIsValid(res);
|
||||
});
|
||||
}, finalDebounce);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const targetComponent = istextarea ? (
|
||||
<textarea
|
||||
@ -67,6 +180,10 @@ export default function Input({
|
||||
setFocus(false);
|
||||
props?.onBlur?.(e);
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
autoComplete={autoComplete}
|
||||
rows={props.height ? Number(props.height) : 4}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@ -85,6 +202,11 @@ export default function Input({
|
||||
setFocus(false);
|
||||
props?.onBlur?.(e);
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
props?.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -93,18 +215,27 @@ export default function Input({
|
||||
{...wrapperProps}
|
||||
className={twMerge(
|
||||
"relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1",
|
||||
focus
|
||||
focus && isValid
|
||||
? "border-slate-700 dark:border-white/50"
|
||||
: "border-slate-300 dark:border-white/20",
|
||||
focus
|
||||
focus && isValid
|
||||
? "outline-slate-700 dark:outline-white/50"
|
||||
: "outline-transparent",
|
||||
variant == "warning" &&
|
||||
isValid &&
|
||||
"border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300",
|
||||
variant == "error" &&
|
||||
isValid &&
|
||||
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300",
|
||||
variant == "inactive" && "opacity-40 pointer-events-none",
|
||||
variant == "inactive" &&
|
||||
isValid &&
|
||||
"opacity-40 pointer-events-none",
|
||||
"bg-white dark:bg-black",
|
||||
isValid
|
||||
? ""
|
||||
: "border-orange-500 outline-orange-500 twui-input-invalid",
|
||||
props.readOnly && "opacity-50 pointer-events-none",
|
||||
"twui-input-wrapper",
|
||||
wrapperProps?.className
|
||||
)}
|
||||
>
|
||||
@ -123,13 +254,22 @@ export default function Input({
|
||||
)}
|
||||
|
||||
{prefix && (
|
||||
<div className="opacity-60 pointer-events-none">{prefix}</div>
|
||||
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetComponent}
|
||||
|
||||
{suffix && (
|
||||
<div className="opacity-60 pointer-events-none">{suffix}</div>
|
||||
<div className="opacity-60 pointer-events-none whitespace-nowrap">
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
{!isValid && (
|
||||
<Span className="opacity-30 pointer-events-none whitespace-nowrap">
|
||||
{invalidMessage || "Invalid"}
|
||||
</Span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -4,6 +4,9 @@ import Input, { InputProps } from "./Input";
|
||||
* # Textarea Component
|
||||
* @className twui-textarea
|
||||
*/
|
||||
export default function Textarea({ componentRef, ...props }: InputProps) {
|
||||
export default function Textarea<KeyType extends string>({
|
||||
componentRef,
|
||||
...props
|
||||
}: InputProps<KeyType>) {
|
||||
return <Input istextarea {...props} componentRef={componentRef} />;
|
||||
}
|
||||
|
66
components/lib/hooks/useIntersectionObserver.tsx
Normal file
66
components/lib/hooks/useIntersectionObserver.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
|
||||
type Param = {
|
||||
elementRef?: React.MutableRefObject<Element | undefined>;
|
||||
className?: string;
|
||||
options?: IntersectionObserverInit;
|
||||
removeIntersected?: boolean;
|
||||
};
|
||||
|
||||
export default function useIntersectionObserver({
|
||||
elementRef,
|
||||
className,
|
||||
options,
|
||||
removeIntersected,
|
||||
}: Param) {
|
||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
|
||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||
(entries, observer) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
setIsIntersecting(true);
|
||||
|
||||
if (removeIntersected) {
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
} else {
|
||||
setIsIntersecting(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = elementRef?.current;
|
||||
const elements = className
|
||||
? document.querySelectorAll(`.${className}`)
|
||||
: null;
|
||||
|
||||
if (!element && !className && refresh < 5) {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
setRefresh(refresh + 1);
|
||||
}, 2000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, {
|
||||
rootMargin: "0px 0px 0px 0px",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (elements) {
|
||||
elements.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
} else if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return { isIntersecting };
|
||||
}
|
25
components/lib/hooks/useLocalUser.tsx
Normal file
25
components/lib/hooks/useLocalUser.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { LocalStorageDict } from "@/dict/local-storage-dict";
|
||||
import { DATASQUIREL_LoggedInUser } from "@moduletrace/datasquirel/package-shared/types";
|
||||
import React from "react";
|
||||
|
||||
export default function useLocalUser() {
|
||||
const [user, setUser] = React.useState<
|
||||
DATASQUIREL_LoggedInUser | null | undefined
|
||||
>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const localUserJSON = localStorage.getItem(
|
||||
LocalStorageDict["User"]
|
||||
);
|
||||
if (localUserJSON) {
|
||||
const localUser = JSON.parse(localUserJSON);
|
||||
setUser(localUser);
|
||||
}
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { user };
|
||||
}
|
144
components/lib/hooks/useWebSocket.tsx
Normal file
144
components/lib/hooks/useWebSocket.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
|
||||
export type UseWebsocketHookParams = {
|
||||
debounce?: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
let reconnectInterval: any;
|
||||
let msgInterval: any;
|
||||
let sendInterval: any;
|
||||
|
||||
let tries = 0;
|
||||
|
||||
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
||||
|
||||
/**
|
||||
* # Use Websocket Hook
|
||||
* @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
|
||||
*
|
||||
* @example window.addEventLiatener("wsDataEvent", (e)=>{
|
||||
* console.log(e.detail.data) // type object
|
||||
* })
|
||||
* @example window.addEventLiatener("wsMessageEvent", (e)=>{
|
||||
* console.log(e.detail.message) // type string
|
||||
* })
|
||||
*/
|
||||
export default function useWebSocket<T>({
|
||||
url,
|
||||
debounce,
|
||||
}: UseWebsocketHookParams) {
|
||||
const DEBOUNCE = debounce || 200;
|
||||
|
||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const messageQueueRef = React.useRef<string[]>([]);
|
||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||
|
||||
// const [message, setMessage] = React.useState<string>("");
|
||||
// const [data, setData] = React.useState<T | null>(null);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
|
||||
const dispatchCustomEvent = React.useCallback(
|
||||
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
||||
const event = new CustomEvent(evtName, {
|
||||
detail: {
|
||||
data: value,
|
||||
message: value,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wsURL = url;
|
||||
if (!wsURL) return;
|
||||
|
||||
const ws = new WebSocket(wsURL);
|
||||
|
||||
ws.onopen = (ev) => {
|
||||
window.clearInterval(reconnectInterval);
|
||||
setSocket(ws);
|
||||
tries = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
window.clearInterval(msgInterval);
|
||||
messageQueueRef.current.push(ev.data);
|
||||
msgInterval = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
console.log("Websocket closed ... Attempting to reconnect ...");
|
||||
reconnectInterval = setInterval(() => {
|
||||
if (tries >= 3) {
|
||||
return window.clearInterval(reconnectInterval);
|
||||
}
|
||||
console.log("Attempting to reconnect ...");
|
||||
setRefresh(refresh + 1);
|
||||
tries++;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return function () {
|
||||
window.clearInterval(reconnectInterval);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Received Message Queue Handler
|
||||
*/
|
||||
const handleReceivedMessageQueue = React.useCallback(() => {
|
||||
if (messageQueueRef.current.length > 0) {
|
||||
const newMessage = messageQueueRef.current.shift();
|
||||
if (!newMessage) return;
|
||||
try {
|
||||
const jsonData = JSON.parse(newMessage);
|
||||
// setData(jsonData);
|
||||
|
||||
dispatchCustomEvent("wsMessageEvent", newMessage);
|
||||
dispatchCustomEvent("wsDataEvent", jsonData);
|
||||
} catch (error) {
|
||||
console.log("Unable to parse string. Returning string.");
|
||||
}
|
||||
} else {
|
||||
window.clearInterval(msgInterval);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Send Message Queue Handler
|
||||
*/
|
||||
const handleSendMessageQueue = React.useCallback(() => {
|
||||
if (sendMessageQueueRef.current.length > 0) {
|
||||
const newMessage = sendMessageQueueRef.current.shift();
|
||||
if (!newMessage) return;
|
||||
socket?.send(newMessage);
|
||||
} else {
|
||||
window.clearInterval(sendInterval);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* # Send Data Function
|
||||
*/
|
||||
const sendData = React.useCallback(
|
||||
(data: T) => {
|
||||
try {
|
||||
window.clearInterval(sendInterval);
|
||||
sendMessageQueueRef.current.push(JSON.stringify(data));
|
||||
sendInterval = setInterval(handleSendMessageQueue, DEBOUNCE);
|
||||
} catch (error: any) {
|
||||
console.log("Error Sending socket message", error.message);
|
||||
}
|
||||
},
|
||||
[socket]
|
||||
);
|
||||
|
||||
return { socket, sendData };
|
||||
}
|
@ -179,7 +179,7 @@ export default function Button({
|
||||
<div
|
||||
{...buttonContentProps}
|
||||
className={twMerge(
|
||||
"flex flex-row items-center gap-2",
|
||||
"flex flex-row items-center gap-2 whitespace-nowrap",
|
||||
loading ? "opacity-0" : "",
|
||||
"twui-button-content-wrapper",
|
||||
buttonContentProps?.className
|
||||
@ -190,7 +190,22 @@ export default function Button({
|
||||
{afterIcon && afterIcon}
|
||||
</div>
|
||||
|
||||
{loading && <Loading className="absolute" />}
|
||||
{loading && (
|
||||
<Loading
|
||||
className="absolute"
|
||||
size={(() => {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return "small";
|
||||
case "smaller":
|
||||
return "smaller";
|
||||
|
||||
default:
|
||||
return "normal";
|
||||
}
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
@ -12,7 +12,7 @@ export default function HR({
|
||||
<hr
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"border-slate-200 dark:border-white/20 w-full my-4",
|
||||
"border-slate-200 dark:border-white/10 w-full my-4",
|
||||
"twui-hr",
|
||||
props.className
|
||||
)}
|
||||
|
82
components/lib/layout/Img.tsx
Normal file
82
components/lib/layout/Img.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import _ from "lodash";
|
||||
import React, { DetailedHTMLProps, ImgHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TWUIImageProps = DetailedHTMLProps<
|
||||
ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
> & {
|
||||
size?: number;
|
||||
circle?: boolean;
|
||||
bgImg?: boolean;
|
||||
backgroundImage?: boolean;
|
||||
fallbackImageSrc?: string;
|
||||
srcLight?: string;
|
||||
srcDark?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Image Component
|
||||
* @className twui-img
|
||||
*/
|
||||
export default function Img({ ...props }: TWUIImageProps) {
|
||||
const width = props.size || props.width;
|
||||
const height = props.size || props.height;
|
||||
const sizeRatio = width && height ? Number(width) / Number(height) : 1;
|
||||
|
||||
const finalProps = _.omit(props, [
|
||||
"size",
|
||||
"circle",
|
||||
"bgImg",
|
||||
"backgroundImage",
|
||||
"fallbackImageSrc",
|
||||
"srcLight",
|
||||
"srcDark",
|
||||
]);
|
||||
|
||||
const interpolatedProps: typeof props = {
|
||||
...finalProps,
|
||||
width: width,
|
||||
height: height,
|
||||
className: twMerge(
|
||||
"object-cover",
|
||||
props.circle && "rounded-full",
|
||||
props.bgImg || props.backgroundImage
|
||||
? "absolute top-0 left-0 w-full h-full object-cover z-0"
|
||||
: "",
|
||||
"twui-img",
|
||||
props.className
|
||||
),
|
||||
onError: (e) => {
|
||||
if (props.fallbackImageSrc) {
|
||||
e.currentTarget.src = props.fallbackImageSrc;
|
||||
}
|
||||
props.onError?.(e);
|
||||
},
|
||||
};
|
||||
|
||||
if (props.srcDark && props.srcLight) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<img
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"hidden dark:block",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcDark}
|
||||
/>
|
||||
<img
|
||||
{...interpolatedProps}
|
||||
className={twMerge(
|
||||
"block dark:hidden",
|
||||
interpolatedProps.className
|
||||
)}
|
||||
src={props.srcLight}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return <img {...interpolatedProps} />;
|
||||
}
|
@ -18,6 +18,7 @@ export default function LoadingRectangleBlock({
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"flex items-center w-full h-10 animate-pulse bg-slate-200 rounded",
|
||||
"dark:bg-slate-800",
|
||||
"twui-loading-rectangle-block",
|
||||
props.className
|
||||
)}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import _ from "lodash";
|
||||
import { DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@ -7,12 +8,16 @@ import { twMerge } from "tailwind-merge";
|
||||
*/
|
||||
export default function Stack({
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
|
||||
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
center?: boolean;
|
||||
}) {
|
||||
const finalProps = _.omit(props, "center");
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...finalProps}
|
||||
className={twMerge(
|
||||
"flex flex-col items-start gap-4",
|
||||
props.center && "items-center",
|
||||
"twui-stack",
|
||||
props.className
|
||||
)}
|
||||
|
18
components/lib/svgs/CheckMarkSVG.tsx
Normal file
18
components/lib/svgs/CheckMarkSVG.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
export default function CheckMarkSVG({ color }: { color?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-check"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -27,26 +27,37 @@ export type FetchApiReturn = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Fetch API
|
||||
*/
|
||||
export default async function fetchApi(
|
||||
url: string,
|
||||
options?: FetchApiOptions,
|
||||
csrf?: boolean
|
||||
csrf?: boolean,
|
||||
/** Key to use to grab local Storage csrf value. */
|
||||
localStorageCSRFKey?: string
|
||||
): Promise<any> {
|
||||
let data;
|
||||
|
||||
const csrfValue = localStorage.getItem(localStorageCSRFKey || "csrf");
|
||||
|
||||
let finalHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
} as FetchHeader;
|
||||
|
||||
if (csrf && csrfValue) {
|
||||
finalHeaders[`'${csrfValue.replace(/\"/g, "")}'`] = "true";
|
||||
}
|
||||
|
||||
if (typeof options === "string") {
|
||||
try {
|
||||
let fetchData;
|
||||
const csrfValue = localStorage.getItem("csrf");
|
||||
|
||||
switch (options) {
|
||||
case "post":
|
||||
fetchData = await fetch(url, {
|
||||
method: options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-auth": csrf ? csrfValue : "",
|
||||
} as FetchHeader,
|
||||
headers: finalHeaders,
|
||||
} as RequestInit);
|
||||
data = fetchData.json();
|
||||
break;
|
||||
@ -64,26 +75,23 @@ export default async function fetchApi(
|
||||
try {
|
||||
let fetchData;
|
||||
|
||||
const csrfValue = localStorage.getItem("csrf");
|
||||
|
||||
if (options.body && typeof options.body === "object") {
|
||||
let oldOptionsBody = _.cloneDeep(options.body);
|
||||
options.body = JSON.stringify(oldOptionsBody);
|
||||
}
|
||||
|
||||
if (options.headers) {
|
||||
options.headers["x-csrf-auth"] = csrf ? csrfValue : "";
|
||||
options.headers = _.merge(options.headers, finalHeaders);
|
||||
|
||||
const finalOptions: any = { ...options };
|
||||
fetchData = await fetch(url, finalOptions);
|
||||
} else {
|
||||
fetchData = await fetch(url, {
|
||||
const finalOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-auth": csrf ? csrfValue : "",
|
||||
} as FetchHeader,
|
||||
} as RequestInit);
|
||||
headers: finalHeaders,
|
||||
} as RequestInit;
|
||||
|
||||
fetchData = await fetch(url, finalOptions);
|
||||
}
|
||||
|
||||
data = fetchData.json();
|
||||
@ -94,7 +102,7 @@ export default async function fetchApi(
|
||||
} else {
|
||||
try {
|
||||
let fetchData = await fetch(url);
|
||||
data = fetchData.json();
|
||||
data = await fetchData.json();
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #3:", error.message);
|
||||
data = null;
|
||||
|
12
pages/api/coderank-code-server-auth.ts
Normal file
12
pages/api/coderank-code-server-auth.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Data = {
|
||||
msg: string;
|
||||
};
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ msg: "Authentication Successful!" });
|
||||
}
|
Loading…
Reference in New Issue
Block a user