This commit is contained in:
Benjamin Toby 2025-01-05 07:25:38 +01:00
parent 998158369a
commit 5587024789
30 changed files with 4756 additions and 75 deletions

View 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"
)}
/>
);
}

File diff suppressed because it is too large Load Diff

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

View File

@ -18,7 +18,7 @@ export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) {
{...props} {...props}
className={twMerge( className={twMerge(
"relative flex items-center gap-2 border rounded", "relative flex items-center gap-2 border rounded",
"border-slate-300 dark:border-white/20", "border-slate-300 dark:border-white/10",
spacing spacing
? spacing == "normal" ? spacing == "normal"
? "px-3 py-2" ? "px-3 py-2"

View File

@ -23,7 +23,7 @@ export default function Breadcrumbs() {
}); });
pathLinks.forEach((linkText, index, array) => { pathLinks.forEach((linkText, index, array) => {
if (!linkText?.match(/./) || index == 1) { if (!linkText?.match(/./)) {
return; return;
} }
@ -56,7 +56,7 @@ export default function Breadcrumbs() {
} }
return ( return (
<Row className="gap-4"> <Row className="gap-4 flex-nowrap whitespace-nowrap overflow-x-auto w-full">
{links.map((linkObject, index, array) => { {links.map((linkObject, index, array) => {
if (index === links.length - 1) { if (index === links.length - 1) {
return ( return (

View File

@ -3,7 +3,11 @@ import { twMerge } from "tailwind-merge";
/** /**
* # General Card * # 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({ export default function Card({
href, href,
@ -24,7 +28,9 @@ export default function Card({
className={twMerge( className={twMerge(
"flex flex-row items-center p-4 rounded bg-white dark:bg-white/10", "flex flex-row items-center p-4 rounded bg-white dark:bg-white/10",
"border border-slate-200 dark:border-white/10 border-solid", "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", "twui-card",
props.className props.className
)} )}
@ -35,7 +41,26 @@ export default function Card({
if (href) { if (href) {
return ( 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} {component}
</a> </a>
); );

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

View File

@ -8,6 +8,7 @@ type Props = DetailedHTMLProps<
HTMLDivElement HTMLDivElement
> & { > & {
target: React.ReactNode; target: React.ReactNode;
targetRef?: React.MutableRefObject<HTMLDivElement | undefined>;
}; };
/** /**
@ -15,11 +16,12 @@ type Props = DetailedHTMLProps<
* @className_wrapper twui-modal-root * @className_wrapper twui-modal-root
* @className_wrapper twui-modal * @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); const [wrapper, setWrapper] = React.useState<HTMLDivElement | null>(null);
React.useEffect(() => { React.useEffect(() => {
const wrapperEl = document.createElement("div"); const wrapperEl = document.createElement("div");
wrapperEl.className = twMerge( wrapperEl.className = twMerge(
"fixed z-[200000] top-0 left-0 w-screen h-screen", "fixed z-[200000] top-0 left-0 w-screen h-screen",
"flex flex-col items-center justify-center", "flex flex-col items-center justify-center",
@ -57,6 +59,7 @@ export default function Modal({ target, ...props }: Props) {
const root = createRoot(wrapper); const root = createRoot(wrapper);
root.render(modalEl); root.render(modalEl);
}} }}
ref={targetRef as any}
> >
{target} {target}
</div> </div>

View File

@ -20,7 +20,7 @@ export default function Paper({
<div <div
{...props} {...props}
className={twMerge( 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", "border border-slate-200 dark:border-white/10 border-solid w-full",
"twui-paper", "twui-paper",
props.className props.className

View File

@ -1,5 +1,5 @@
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Input from "../form/Input"; import Input, { InputProps } from "../form/Input";
import Button from "../layout/Button"; import Button from "../layout/Button";
import Row from "../layout/Row"; import Row from "../layout/Row";
import { Search as SearchIcon } from "lucide-react"; import { Search as SearchIcon } from "lucide-react";
@ -11,20 +11,13 @@ import React, {
let timeout: any; let timeout: any;
export type SearchProps = DetailedHTMLProps< export type SearchProps<KeyType extends string> = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
> & { > & {
dispatch?: (value?: string) => void; dispatch?: (value?: string) => void;
delay?: number; delay?: number;
inputProps?: DetailedHTMLProps< inputProps?: InputProps<KeyType>;
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> &
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>;
buttonProps?: DetailedHTMLProps< buttonProps?: DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
@ -37,13 +30,13 @@ export type SearchProps = DetailedHTMLProps<
* @className_circle twui-search-input * @className_circle twui-search-input
* @className_circle twui-search-button * @className_circle twui-search-button
*/ */
export default function Search({ export default function Search<KeyType extends string>({
dispatch, dispatch,
delay = 500, delay = 500,
inputProps, inputProps,
buttonProps, buttonProps,
...props ...props
}: SearchProps) { }: SearchProps<KeyType>) {
const [input, setInput] = React.useState(""); const [input, setInput] = React.useState("");
React.useEffect(() => { React.useEffect(() => {

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

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

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

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

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

View File

@ -1,3 +1,4 @@
import _ from "lodash";
import { DetailedHTMLProps, FormHTMLAttributes } from "react"; import { DetailedHTMLProps, FormHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@ -5,17 +6,28 @@ import { twMerge } from "tailwind-merge";
* # Form Element * # Form Element
* @className twui-form * @className twui-form
*/ */
export default function Form({ export default function Form<T extends object = { [key: string]: any }>({
...props ...props
}: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>) { }: DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
submitHandler?: (e: React.FormEvent<HTMLFormElement>, data: T) => void;
}) {
const finalProps = _.omit(props, "submitHandler");
return ( return (
<form <form
{...props} {...finalProps}
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) => {
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} {props.children}
</form> </form>

View File

@ -1,25 +1,24 @@
import Button from "@/components/lib/layout/Button"; import Button from "../layout/Button";
import Stack from "@/components/lib/layout/Stack"; import Stack from "../layout/Stack";
import { ImagePlus, X } from "lucide-react"; import { ImagePlus, X } from "lucide-react";
import React, { DetailedHTMLProps } from "react"; import React, { DetailedHTMLProps } from "react";
import Card from "@/components/lib/elements/Card"; import Card from "../elements/Card";
import Span from "@/components/lib/layout/Span"; import Span from "../layout/Span";
import Center from "@/components/lib/layout/Center"; import Center from "../layout/Center";
import imageInputToBase64, { import imageInputToBase64, {
ImageInputToBase64FunctionReturn, ImageInputToBase64FunctionReturn,
} from "../utils/form/imageInputToBase64"; } from "../utils/form/imageInputToBase64";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
type ImageUploadProps = { type ImageUploadProps = DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
onChange?: (imgData: ImageInputToBase64FunctionReturn | undefined) => any; onChange?: (imgData: ImageInputToBase64FunctionReturn | undefined) => any;
fileInputProps?: DetailedHTMLProps< fileInputProps?: DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
>; >;
wrapperProps?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
placeHolderWrapper?: DetailedHTMLProps< placeHolderWrapper?: DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement HTMLDivElement
@ -32,23 +31,27 @@ type ImageUploadProps = {
React.ImgHTMLAttributes<HTMLImageElement>, React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement HTMLImageElement
>; >;
label?: string;
disablePreview?: boolean;
}; };
export default function ImageUpload({ export default function ImageUpload({
onChange, onChange,
fileInputProps, fileInputProps,
wrapperProps,
placeHolderWrapper, placeHolderWrapper,
previewImageWrapperProps, previewImageWrapperProps,
previewImageProps, previewImageProps,
label,
disablePreview,
...props
}: ImageUploadProps) { }: ImageUploadProps) {
const [src, setSrc] = React.useState<string | undefined>(undefined); const [src, setSrc] = React.useState<string | undefined>(undefined);
const inputRef = React.useRef<HTMLInputElement>(); const inputRef = React.useRef<HTMLInputElement>();
return ( return (
<Stack <Stack
className={twMerge("w-full", wrapperProps?.className)} {...props}
{...wrapperProps} className={twMerge("w-full h-[300px]", props?.className)}
> >
<input <input
type="file" type="file"
@ -65,12 +68,21 @@ export default function ImageUpload({
/> />
{src ? ( {src ? (
<Card className="w-full relative" {...previewImageWrapperProps}> <Card
<img className="w-full relative h-full items-center justify-center"
src={src} {...previewImageWrapperProps}
className="w-full h-[300px] object-contain" >
{...previewImageProps} {disablePreview ? (
/> <Span className="opacity-50" size="small">
Image Uploaded!
</Span>
) : (
<img
src={src}
className="w-full object-contain"
{...previewImageProps}
/>
)}
<Button <Button
variant="ghost" variant="ghost"
className="absolute p-2 top-2 right-2 z-20" className="absolute p-2 top-2 right-2 z-20"
@ -79,13 +91,13 @@ export default function ImageUpload({
onChange?.(undefined); onChange?.(undefined);
}} }}
> >
<X className="text-slate-950" /> <X className="text-slate-950 dark:text-white" />
</Button> </Button>
</Card> </Card>
) : ( ) : (
<Card <Card
className={twMerge( 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 placeHolderWrapper?.className
)} )}
onClick={(e) => { onClick={(e) => {
@ -98,7 +110,7 @@ export default function ImageUpload({
<Stack className="items-center gap-2"> <Stack className="items-center gap-2">
<ImagePlus className="text-slate-400" /> <ImagePlus className="text-slate-400" />
<Span size="smaller" variant="faded"> <Span size="smaller" variant="faded">
Click to Upload Image {label || "Click to Upload Image"}
</Span> </Span>
</Stack> </Stack>
</Center> </Center>

View File

@ -6,8 +6,79 @@ import React, {
TextareaHTMLAttributes, TextareaHTMLAttributes,
} from "react"; } from "react";
import { twMerge } from "tailwind-merge"; 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>, InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> & > &
@ -30,13 +101,21 @@ export type InputProps = DetailedHTMLProps<
HTMLLabelElement HTMLLabelElement
>; >;
componentRef?: RefObject<any>; componentRef?: RefObject<any>;
validationRegex?: RegExp;
debounce?: number;
invalidMessage?: string;
validationFunction?: (value: string) => Promise<boolean>;
autoComplete?: (typeof autocompleteOptions)[number];
name?: KeyType;
}; };
/** /**
* # Input Element * # Input Element
* @className twui-input * @className twui-input
* @className twui-input-wrapper
* @className twui-input-invalid
*/ */
export default function Input({ export default function Input<KeyType extends string>({
label, label,
variant, variant,
prefix, prefix,
@ -46,9 +125,43 @@ export default function Input({
wrapperProps, wrapperProps,
showLabel, showLabel,
istextarea, istextarea,
debounce,
invalidMessage,
autoComplete,
validationFunction,
validationRegex,
...props ...props
}: InputProps) { }: InputProps<KeyType>) {
const [focus, setFocus] = React.useState(false); 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 ? ( const targetComponent = istextarea ? (
<textarea <textarea
@ -67,6 +180,10 @@ export default function Input({
setFocus(false); setFocus(false);
props?.onBlur?.(e); props?.onBlur?.(e);
}} }}
value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete={autoComplete}
rows={props.height ? Number(props.height) : 4}
/> />
) : ( ) : (
<input <input
@ -85,6 +202,11 @@ export default function Input({
setFocus(false); setFocus(false);
props?.onBlur?.(e); props?.onBlur?.(e);
}} }}
value={value}
onChange={(e) => {
setValue(e.target.value);
props?.onChange?.(e);
}}
/> />
); );
@ -93,18 +215,27 @@ export default function Input({
{...wrapperProps} {...wrapperProps}
className={twMerge( className={twMerge(
"relative flex items-center gap-2 border rounded-md px-3 py-2 outline outline-1", "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-700 dark:border-white/50"
: "border-slate-300 dark:border-white/20", : "border-slate-300 dark:border-white/20",
focus focus && isValid
? "outline-slate-700 dark:outline-white/50" ? "outline-slate-700 dark:outline-white/50"
: "outline-transparent", : "outline-transparent",
variant == "warning" && variant == "warning" &&
isValid &&
"border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300", "border-yellow-500 dark:border-yellow-300 outline-yellow-500 dark:outline-yellow-300",
variant == "error" && variant == "error" &&
isValid &&
"border-red-500 dark:border-red-300 outline-red-500 dark:outline-red-300", "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", "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 wrapperProps?.className
)} )}
> >
@ -123,13 +254,22 @@ export default function Input({
)} )}
{prefix && ( {prefix && (
<div className="opacity-60 pointer-events-none">{prefix}</div> <div className="opacity-60 pointer-events-none whitespace-nowrap">
{prefix}
</div>
)} )}
{targetComponent} {targetComponent}
{suffix && ( {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> </div>
); );

View File

@ -4,6 +4,9 @@ import Input, { InputProps } from "./Input";
* # Textarea Component * # Textarea Component
* @className twui-textarea * @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} />; return <Input istextarea {...props} componentRef={componentRef} />;
} }

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

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

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

View File

@ -179,7 +179,7 @@ export default function Button({
<div <div
{...buttonContentProps} {...buttonContentProps}
className={twMerge( className={twMerge(
"flex flex-row items-center gap-2", "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
@ -190,7 +190,22 @@ export default function Button({
{afterIcon && afterIcon} {afterIcon && afterIcon}
</div> </div>
{loading && <Loading className="absolute" />} {loading && (
<Loading
className="absolute"
size={(() => {
switch (size) {
case "small":
return "small";
case "smaller":
return "smaller";
default:
return "normal";
}
})()}
/>
)}
</button> </button>
); );

View File

@ -12,7 +12,7 @@ export default function HR({
<hr <hr
{...props} {...props}
className={twMerge( 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", "twui-hr",
props.className props.className
)} )}

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

View File

@ -18,6 +18,7 @@ export default function LoadingRectangleBlock({
{...props} {...props}
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",
"twui-loading-rectangle-block", "twui-loading-rectangle-block",
props.className props.className
)} )}

View File

@ -1,3 +1,4 @@
import _ from "lodash";
import { DetailedHTMLProps, HTMLAttributes } from "react"; import { DetailedHTMLProps, HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@ -7,12 +8,16 @@ import { twMerge } from "tailwind-merge";
*/ */
export default function Stack({ export default function Stack({
...props ...props
}: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) { }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
center?: boolean;
}) {
const finalProps = _.omit(props, "center");
return ( return (
<div <div
{...props} {...finalProps}
className={twMerge( className={twMerge(
"flex flex-col items-start gap-4", "flex flex-col items-start gap-4",
props.center && "items-center",
"twui-stack", "twui-stack",
props.className props.className
)} )}

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

View File

@ -27,26 +27,37 @@ export type FetchApiReturn = {
[key: string]: any; [key: string]: any;
}; };
/**
* # Fetch API
*/
export default async function fetchApi( export default async function fetchApi(
url: string, url: string,
options?: FetchApiOptions, options?: FetchApiOptions,
csrf?: boolean csrf?: boolean,
/** Key to use to grab local Storage csrf value. */
localStorageCSRFKey?: string
): Promise<any> { ): Promise<any> {
let data; 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") { if (typeof options === "string") {
try { try {
let fetchData; let fetchData;
const csrfValue = localStorage.getItem("csrf");
switch (options) { switch (options) {
case "post": case "post":
fetchData = await fetch(url, { fetchData = await fetch(url, {
method: options, method: options,
headers: { headers: finalHeaders,
"Content-Type": "application/json",
"x-csrf-auth": csrf ? csrfValue : "",
} as FetchHeader,
} as RequestInit); } as RequestInit);
data = fetchData.json(); data = fetchData.json();
break; break;
@ -64,26 +75,23 @@ export default async function fetchApi(
try { try {
let fetchData; let fetchData;
const csrfValue = localStorage.getItem("csrf");
if (options.body && typeof options.body === "object") { if (options.body && typeof options.body === "object") {
let oldOptionsBody = _.cloneDeep(options.body); let oldOptionsBody = _.cloneDeep(options.body);
options.body = JSON.stringify(oldOptionsBody); options.body = JSON.stringify(oldOptionsBody);
} }
if (options.headers) { if (options.headers) {
options.headers["x-csrf-auth"] = csrf ? csrfValue : ""; options.headers = _.merge(options.headers, finalHeaders);
const finalOptions: any = { ...options }; const finalOptions: any = { ...options };
fetchData = await fetch(url, finalOptions); fetchData = await fetch(url, finalOptions);
} else { } else {
fetchData = await fetch(url, { const finalOptions = {
...options, ...options,
headers: { headers: finalHeaders,
"Content-Type": "application/json", } as RequestInit;
"x-csrf-auth": csrf ? csrfValue : "",
} as FetchHeader, fetchData = await fetch(url, finalOptions);
} as RequestInit);
} }
data = fetchData.json(); data = fetchData.json();
@ -94,7 +102,7 @@ export default async function fetchApi(
} else { } else {
try { try {
let fetchData = await fetch(url); let fetchData = await fetch(url);
data = 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);
data = null; data = null;

View 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!" });
}