diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..1b4726a --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fd9d94a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a79b60a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Set Node.js version +FROM node:20-alpine + +RUN apk update && apk add --no-cache curl bash nano + +SHELL ["/bin/bash", "-c"] + +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:${PATH}" + +RUN mkdir /app + +# Set working directory +WORKDIR /app + +RUN touch /root/.bashrc +RUN echo 'alias ll="ls -laF"' >/root/.bashrc + +COPY . /app/. + +# Install dependencies +RUN bun install + +RUN bun run build + +# Run the app +CMD ["bun", "start"] diff --git a/README.md b/README.md index ef0e47e..a34b2ff 100644 --- a/README.md +++ b/README.md @@ -1,40 +1 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages. - -This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details. +# Welcome to Tben diff --git a/bun.lockb b/bun.lockb index daeb4c5..a1f3179 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/general/Logo.tsx b/components/general/Logo.tsx new file mode 100644 index 0000000..f2ef82a --- /dev/null +++ b/components/general/Logo.tsx @@ -0,0 +1,22 @@ +type Props = { + size?: number; +}; +export default function Logo({ size }: Props) { + const sizeRatio = 50 / 100; + const width = size || 50; + const height = width * sizeRatio; + + return ( + + Main Logo + + ); +} diff --git a/components/lib/editors/AceEditor.tsx b/components/lib/editors/AceEditor.tsx new file mode 100755 index 0000000..f64cde0 --- /dev/null +++ b/components/lib/editors/AceEditor.tsx @@ -0,0 +1,136 @@ +import { AceEditorAcceptedModes } from "@/components/general/data/partials/EditDataListButton"; +import React, { MutableRefObject } from "react"; +import { twMerge } from "tailwind-merge"; + +export type AceEditorComponentType = { + editorRef?: MutableRefObject; + readOnly?: boolean; + /** Function to call when Ctrl+Enter is pressed */ + ctrlEnterFn?: (editor: AceAjax.Editor) => void; + content?: string; + placeholder?: string; + mode?: AceEditorAcceptedModes; + fontSize?: string; + previewMode?: boolean; + onChange?: (value: string) => void; + delay?: number; +}; + +let timeout: any; + +/** + * # Powerful Ace Editor + * @note **NOTE** head scripts required + * @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js` + * @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js` + */ +export default function AceEditor({ + editorRef, + readOnly, + ctrlEnterFn, + content = "", + placeholder, + mode, + fontSize, + previewMode, + onChange, + delay = 500, +}: AceEditorComponentType) { + try { + const editorElementRef = React.useRef(); + const editorRefInstance = React.useRef(); + + const [refresh, setRefresh] = React.useState(0); + const [darkMode, setDarkMode] = React.useState(false); + const [ready, setReady] = React.useState(false); + + React.useEffect(() => { + if (!ready) return; + + if (!ace?.edit || !editorElementRef.current) { + setTimeout(() => { + setRefresh((prev) => prev + 1); + }, 1000); + return; + } + + const editor = ace.edit(editorElementRef.current); + + editor.setOptions({ + mode: `ace/mode/${mode ? mode : "javascript"}`, + theme: darkMode + ? "ace/theme/tomorrow_night_bright" + : "ace/theme/ace_light", + value: content, + placeholder: placeholder ? placeholder : "", + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, + readOnly: readOnly ? true : false, + fontSize: fontSize ? fontSize : null, + showLineNumbers: previewMode ? false : true, + wrap: true, + wrapMethod: "code", + // onchange: (e) => { + // console.log(e); + // }, + }); + + editor.commands.addCommand({ + name: "myCommand", + bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" }, + exec: function (editor) { + if (ctrlEnterFn) ctrlEnterFn(editor); + }, + readOnly: true, + }); + + editor.getSession().on("change", function (e) { + if (onChange) { + clearTimeout(timeout); + + setTimeout(() => { + onChange(editor.getValue()); + console.log(editor.getValue()); + }, delay); + } + }); + + editorRefInstance.current = editor; + if (editorRef) editorRef.current = editor; + }, [refresh, darkMode, ready]); + + React.useEffect(() => { + const htmlClassName = document.documentElement.className; + if (htmlClassName.match(/dark/i)) setDarkMode(true); + setTimeout(() => { + setReady(true); + }, 200); + }, []); + + return ( + +
+
+
+
+ ); + } catch (error: any) { + return ( + + + Editor Error:{" "} + {error.message} + + + ); + } +} diff --git a/components/lib/elements/Border.tsx b/components/lib/elements/Border.tsx new file mode 100644 index 0000000..0e98ff4 --- /dev/null +++ b/components/lib/elements/Border.tsx @@ -0,0 +1,36 @@ +import { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +export type TWUI_BORDER_PROPS = DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement +> & { + spacing?: "normal" | "loose" | "tight" | "wide" | "tightest"; +}; + +/** + * # Toggle Component + * @className_wrapper twui-border + */ +export default function Border({ spacing, ...props }: TWUI_BORDER_PROPS) { + return ( +
+ {props.children} +
+ ); +} diff --git a/components/lib/elements/Breadcrumbs.tsx b/components/lib/elements/Breadcrumbs.tsx new file mode 100755 index 0000000..4af8e57 --- /dev/null +++ b/components/lib/elements/Breadcrumbs.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import Link from "../layout/Link"; +import Divider from "../layout/Divider"; +import Row from "../layout/Row"; +import lowerToTitleCase from "@/server-client-shared/utils/lower-to-title-case"; + +type LinkObject = { + title: string; + path: string; +}; +export default function Breadcrumbs() { + const [links, setLinks] = React.useState(null); + + React.useEffect(() => { + let pathname = window.location.pathname; + let pathLinks = pathname.split("/"); + + let validPathLinks = []; + + validPathLinks.push({ + title: "Home", + path: pathname.match(/admin/) ? "/admin" : "/", + }); + + pathLinks.forEach((linkText, index, array) => { + if (!linkText?.match(/./) || index == 1) { + return; + } + + validPathLinks.push({ + title: lowerToTitleCase(linkText), + path: (() => { + let path = ""; + + for (let i = 0; i < array.length; i++) { + const lnText = array[i]; + if (i > index || !lnText.match(/./)) continue; + + path += `/${lnText}`; + } + + return path; + })(), + }); + }); + + setLinks(validPathLinks); + + return function () { + setLinks(null); + }; + }, []); + + if (!links?.[1]) { + return ; + } + + return ( + + {links.map((linkObject, index, array) => { + if (index === links.length - 1) { + return ( + + {linkObject.title} + + ); + } else { + return ( + + + {linkObject.title} + + + + ); + } + })} + + ); + //////////////////////////////////////// + //////////////////////////////////////// + //////////////////////////////////////// +} + +/** ****************************************************************************** */ +/** ****************************************************************************** */ +/** ****************************************************************************** */ +/** ****************************************************************************** */ +/** ****************************************************************************** */ +/** ****************************************************************************** */ diff --git a/components/lib/elements/Card.tsx b/components/lib/elements/Card.tsx new file mode 100644 index 0000000..7e4996e --- /dev/null +++ b/components/lib/elements/Card.tsx @@ -0,0 +1,45 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +/** + * # General Card + * @className_wrapper twui-card + */ +export default function Card({ + href, + variant, + linkProps, + ...props +}: DetailedHTMLProps, HTMLDivElement> & { + variant?: "normal"; + href?: string; + linkProps?: DetailedHTMLProps< + React.AnchorHTMLAttributes, + HTMLAnchorElement + >; +}) { + const component = ( +
+ {props.children} +
+ ); + + if (href) { + return ( + + {component} + + ); + } + + return component; +} diff --git a/components/lib/elements/ColorSchemeSelector.tsx b/components/lib/elements/ColorSchemeSelector.tsx new file mode 100644 index 0000000..40c6f66 --- /dev/null +++ b/components/lib/elements/ColorSchemeSelector.tsx @@ -0,0 +1,39 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; +import Toggle, { TWUI_TOGGLE_PROPS } from "./Toggle"; + +/** + * # Color Scheme Loader + * @className_wrapper twui-color-scheme-selector + */ +export default function ColorSchemeSelector({ + active, + setActive, + toggleProps, + ...props +}: DetailedHTMLProps, HTMLDivElement> & { + toggleProps?: TWUI_TOGGLE_PROPS; + active: boolean; + setActive: React.Dispatch>; +}) { + React.useEffect(() => { + if (active) { + document.documentElement.className = "dark"; + } else { + document.documentElement.className = ""; + } + }, [active]); + + return ( +
+ +
+ ); +} diff --git a/components/lib/elements/Loading.tsx b/components/lib/elements/Loading.tsx new file mode 100644 index 0000000..602be92 --- /dev/null +++ b/components/lib/elements/Loading.tsx @@ -0,0 +1,58 @@ +import { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement +> & { + size?: "small" | "normal" | "medium" | "large" | "smaller"; + svgClassName?: string; +}; + +/** + * # Loading Component + * @className_wrapper twui-loading + */ +export default function Loading({ size, svgClassName, ...props }: Props) { + const sizeClassName = (() => { + switch (size) { + case "smaller": + return "w-4 h-4"; + case "small": + return "w-5 h-5"; + case "normal": + return "w-6 h-6"; + case "large": + return "w-7 h-7"; + + default: + return "w-6 h-6"; + } + })(); + + return ( +
+ +
+ ); +} diff --git a/components/lib/elements/LoadingBlock.tsx b/components/lib/elements/LoadingBlock.tsx new file mode 100644 index 0000000..9698a30 --- /dev/null +++ b/components/lib/elements/LoadingBlock.tsx @@ -0,0 +1,24 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +/** + * # General paper + * @className_wrapper twui-loading-block + */ +export default function LoadingBlock({ + ...props +}: DetailedHTMLProps, HTMLDivElement>) { + return ( +
+ {props.children} +
+ ); +} diff --git a/components/lib/elements/Modal.tsx b/components/lib/elements/Modal.tsx new file mode 100644 index 0000000..f48277d --- /dev/null +++ b/components/lib/elements/Modal.tsx @@ -0,0 +1,71 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; +import { createRoot } from "react-dom/client"; +import Paper from "./Paper"; + +type Props = DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement +> & { + target: React.ReactNode; +}; + +/** + * # Modal Component + * @className_wrapper twui-modal-root + * @className_wrapper twui-modal + */ +export default function Modal({ target, ...props }: Props) { + const [wrapper, setWrapper] = React.useState(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", + "twui-modal-root" + ); + + setWrapper(wrapperEl); + }, []); + + const modalEl = ( + +
{ + closeModal({ wrapperEl: wrapper }); + }} + >
+ + {props.children} + +
+ ); + + const targetEl = ( +
{ + if (!wrapper) return; + document.body.appendChild(wrapper); + const root = createRoot(wrapper); + root.render(modalEl); + }} + > + {target} +
+ ); + + return targetEl; +} + +function closeModal({ wrapperEl }: { wrapperEl: HTMLDivElement | null }) { + if (!wrapperEl) return; + wrapperEl.parentElement?.removeChild(wrapperEl); +} diff --git a/components/lib/elements/Paper.tsx b/components/lib/elements/Paper.tsx new file mode 100644 index 0000000..3346ba0 --- /dev/null +++ b/components/lib/elements/Paper.tsx @@ -0,0 +1,32 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +/** + * # General paper + * @className_wrapper twui-paper + */ +export default function Paper({ + variant, + linkProps, + ...props +}: DetailedHTMLProps, HTMLDivElement> & { + variant?: "normal"; + linkProps?: DetailedHTMLProps< + React.AnchorHTMLAttributes, + HTMLAnchorElement + >; +}) { + return ( +
+ {props.children} +
+ ); +} diff --git a/components/lib/elements/Search.tsx b/components/lib/elements/Search.tsx new file mode 100644 index 0000000..587681a --- /dev/null +++ b/components/lib/elements/Search.tsx @@ -0,0 +1,110 @@ +import { twMerge } from "tailwind-merge"; +import Input from "../form/Input"; +import Button from "../layout/Button"; +import Row from "../layout/Row"; +import { Search as SearchIcon } from "lucide-react"; +import React, { + DetailedHTMLProps, + InputHTMLAttributes, + TextareaHTMLAttributes, +} from "react"; + +let timeout: any; + +export type SearchProps = DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +> & { + dispatch?: (value?: string) => void; + delay?: number; + inputProps?: DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement + > & + DetailedHTMLProps< + TextareaHTMLAttributes, + HTMLTextAreaElement + >; + buttonProps?: DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + >; +}; + +/** + * # Search Component + * @className_wrapper twui-search-wrapper + * @className_circle twui-search-input + * @className_circle twui-search-button + */ +export default function Search({ + dispatch, + delay = 500, + inputProps, + buttonProps, + ...props +}: SearchProps) { + const [input, setInput] = React.useState(""); + + React.useEffect(() => { + clearTimeout(timeout); + + timeout = setTimeout(() => { + dispatch?.(input); + }, delay); + }, [input]); + + const inputRef = React.useRef(); + + React.useEffect(() => { + if (props.autoFocus) { + inputRef.current?.focus(); + } + }, []); + + return ( + + setInput(e.target.value)} + className={twMerge( + "rounded-r-none", + "twui-search-input", + inputProps?.className + )} + wrapperProps={{ + className: "rounded-r-none", + }} + componentRef={inputRef} + /> + + + ); +} diff --git a/components/lib/elements/Toggle.tsx b/components/lib/elements/Toggle.tsx new file mode 100644 index 0000000..ad276c4 --- /dev/null +++ b/components/lib/elements/Toggle.tsx @@ -0,0 +1,52 @@ +import { DetailedHTMLProps, HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +export type TWUI_TOGGLE_PROPS = DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement +> & { + active?: boolean; + setActive?: React.Dispatch>; + circleProps?: DetailedHTMLProps< + HTMLAttributes, + HTMLDivElement + >; +}; + +/** + * # Toggle Component + * @className_wrapper twui-toggle-wrapper + * @className_circle twui-toggle-circle + */ +export default function Toggle({ + circleProps, + active, + setActive, + ...props +}: TWUI_TOGGLE_PROPS) { + return ( +
setActive?.(!active)} + > +
+
+ ); +} diff --git a/components/lib/form/Form.tsx b/components/lib/form/Form.tsx new file mode 100644 index 0000000..b0d92eb --- /dev/null +++ b/components/lib/form/Form.tsx @@ -0,0 +1,23 @@ +import { DetailedHTMLProps, FormHTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +/** + * # Form Element + * @className twui-form + */ +export default function Form({ + ...props +}: DetailedHTMLProps, HTMLFormElement>) { + return ( +
+ {props.children} +
+ ); +} diff --git a/components/lib/form/ImageUpload.tsx b/components/lib/form/ImageUpload.tsx new file mode 100644 index 0000000..9869aad --- /dev/null +++ b/components/lib/form/ImageUpload.tsx @@ -0,0 +1,109 @@ +import Button from "@/components/lib/layout/Button"; +import Stack from "@/components/lib/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 imageInputToBase64, { + ImageInputToBase64FunctionReturn, +} from "../utils/form/imageInputToBase64"; +import { twMerge } from "tailwind-merge"; + +type ImageUploadProps = { + onChange?: (imgData: ImageInputToBase64FunctionReturn | undefined) => any; + fileInputProps?: DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + >; + wrapperProps?: DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; + placeHolderWrapper?: DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; + previewImageWrapperProps?: DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; + previewImageProps?: DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement + >; +}; + +export default function ImageUpload({ + onChange, + fileInputProps, + wrapperProps, + placeHolderWrapper, + previewImageWrapperProps, + previewImageProps, +}: ImageUploadProps) { + const [src, setSrc] = React.useState(undefined); + const inputRef = React.useRef(); + + return ( + + { + imageInputToBase64({ imageInput: e.target }).then((res) => { + setSrc(res.imageBase64Full); + onChange?.(res); + fileInputProps?.onChange?.(e); + }); + }} + ref={inputRef as any} + /> + + {src ? ( + + + + + ) : ( + { + inputRef.current?.click(); + placeHolderWrapper?.onClick?.(e); + }} + {...placeHolderWrapper} + > +
+ + + + Click to Upload Image + + +
+
+ )} +
+ ); +} diff --git a/components/lib/form/Input.tsx b/components/lib/form/Input.tsx new file mode 100644 index 0000000..e02050f --- /dev/null +++ b/components/lib/form/Input.tsx @@ -0,0 +1,136 @@ +import React, { + DetailedHTMLProps, + InputHTMLAttributes, + LabelHTMLAttributes, + RefObject, + TextareaHTMLAttributes, +} from "react"; +import { twMerge } from "tailwind-merge"; + +export type InputProps = DetailedHTMLProps< + InputHTMLAttributes, + HTMLInputElement +> & + DetailedHTMLProps< + TextareaHTMLAttributes, + HTMLTextAreaElement + > & { + label?: string; + variant?: "normal" | "warning" | "error" | "inactive"; + prefix?: string | React.ReactNode; + suffix?: string | React.ReactNode; + showLabel?: boolean; + istextarea?: boolean; + wrapperProps?: DetailedHTMLProps< + InputHTMLAttributes, + HTMLDivElement + >; + labelProps?: DetailedHTMLProps< + LabelHTMLAttributes, + HTMLLabelElement + >; + componentRef?: RefObject; + }; + +/** + * # Input Element + * @className twui-input + */ +export default function Input({ + label, + variant, + prefix, + suffix, + componentRef, + labelProps, + wrapperProps, + showLabel, + istextarea, + ...props +}: InputProps) { + const [focus, setFocus] = React.useState(false); + + const targetComponent = istextarea ? ( +