ui-lib.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. /* eslint-disable @next/next/no-img-element */
  2. import styles from "./ui-lib.module.scss";
  3. import LoadingIcon from "../icons/three-dots.svg";
  4. import CloseIcon from "../icons/close.svg";
  5. import EyeIcon from "../icons/eye.svg";
  6. import EyeOffIcon from "../icons/eye-off.svg";
  7. import DownIcon from "../icons/down.svg";
  8. import ConfirmIcon from "../icons/confirm.svg";
  9. import CancelIcon from "../icons/cancel.svg";
  10. import MaxIcon from "../icons/max.svg";
  11. import MinIcon from "../icons/min.svg";
  12. import Locale from "../locales";
  13. import { createRoot } from "react-dom/client";
  14. import React, { HTMLProps, useEffect, useState } from "react";
  15. import { IconButton } from "./button";
  16. export function Popover(props: {
  17. children: JSX.Element;
  18. content: JSX.Element;
  19. open?: boolean;
  20. onClose?: () => void;
  21. }) {
  22. return (
  23. <div className={styles.popover}>
  24. {props.children}
  25. {props.open && (
  26. <div className={styles["popover-content"]}>
  27. <div className={styles["popover-mask"]} onClick={props.onClose}></div>
  28. {props.content}
  29. </div>
  30. )}
  31. </div>
  32. );
  33. }
  34. export function Card(props: { children: JSX.Element[]; className?: string }) {
  35. return (
  36. <div className={styles.card + " " + props.className}>{props.children}</div>
  37. );
  38. }
  39. export function ListItem(props: {
  40. title: string;
  41. subTitle?: string;
  42. children?: JSX.Element | JSX.Element[];
  43. icon?: JSX.Element;
  44. className?: string;
  45. }) {
  46. return (
  47. <div className={styles["list-item"] + ` ${props.className || ""}`}>
  48. <div className={styles["list-header"]}>
  49. {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
  50. <div className={styles["list-item-title"]}>
  51. <div>{props.title}</div>
  52. {props.subTitle && (
  53. <div className={styles["list-item-sub-title"]}>
  54. {props.subTitle}
  55. </div>
  56. )}
  57. </div>
  58. </div>
  59. {props.children}
  60. </div>
  61. );
  62. }
  63. export function List(props: {
  64. children:
  65. | Array<JSX.Element | null | undefined>
  66. | JSX.Element
  67. | null
  68. | undefined;
  69. }) {
  70. return <div className={styles.list}>{props.children}</div>;
  71. }
  72. export function Loading() {
  73. return (
  74. <div
  75. style={{
  76. height: "100vh",
  77. width: "100vw",
  78. display: "flex",
  79. alignItems: "center",
  80. justifyContent: "center",
  81. }}
  82. >
  83. <LoadingIcon />
  84. </div>
  85. );
  86. }
  87. interface ModalProps {
  88. title: string;
  89. children?: any;
  90. actions?: JSX.Element[];
  91. defaultMax?: boolean;
  92. onClose?: () => void;
  93. }
  94. export function Modal(props: ModalProps) {
  95. useEffect(() => {
  96. const onKeyDown = (e: KeyboardEvent) => {
  97. if (e.key === "Escape") {
  98. props.onClose?.();
  99. }
  100. };
  101. window.addEventListener("keydown", onKeyDown);
  102. return () => {
  103. window.removeEventListener("keydown", onKeyDown);
  104. };
  105. // eslint-disable-next-line react-hooks/exhaustive-deps
  106. }, []);
  107. const [isMax, setMax] = useState(!!props.defaultMax);
  108. return (
  109. <div
  110. className={
  111. styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
  112. }
  113. >
  114. <div className={styles["modal-header"]}>
  115. <div className={styles["modal-title"]}>{props.title}</div>
  116. <div className={styles["modal-header-actions"]}>
  117. <div
  118. className={styles["modal-header-action"]}
  119. onClick={() => setMax(!isMax)}
  120. >
  121. {isMax ? <MinIcon /> : <MaxIcon />}
  122. </div>
  123. <div
  124. className={styles["modal-header-action"]}
  125. onClick={props.onClose}
  126. >
  127. <CloseIcon />
  128. </div>
  129. </div>
  130. </div>
  131. <div className={styles["modal-content"]}>{props.children}</div>
  132. <div className={styles["modal-footer"]}>
  133. <div className={styles["modal-actions"]}>
  134. {props.actions?.map((action, i) => (
  135. <div key={i} className={styles["modal-action"]}>
  136. {action}
  137. </div>
  138. ))}
  139. </div>
  140. </div>
  141. </div>
  142. );
  143. }
  144. export function showModal(props: ModalProps) {
  145. const div = document.createElement("div");
  146. div.className = "modal-mask";
  147. document.body.appendChild(div);
  148. const root = createRoot(div);
  149. const closeModal = () => {
  150. props.onClose?.();
  151. root.unmount();
  152. div.remove();
  153. };
  154. div.onclick = (e) => {
  155. if (e.target === div) {
  156. closeModal();
  157. }
  158. };
  159. root.render(<Modal {...props} onClose={closeModal}></Modal>);
  160. }
  161. export type ToastProps = {
  162. content: string;
  163. action?: {
  164. text: string;
  165. onClick: () => void;
  166. };
  167. onClose?: () => void;
  168. };
  169. export function Toast(props: ToastProps) {
  170. return (
  171. <div className={styles["toast-container"]}>
  172. <div className={styles["toast-content"]}>
  173. <span>{props.content}</span>
  174. {props.action && (
  175. <button
  176. onClick={() => {
  177. props.action?.onClick?.();
  178. props.onClose?.();
  179. }}
  180. className={styles["toast-action"]}
  181. >
  182. {props.action.text}
  183. </button>
  184. )}
  185. </div>
  186. </div>
  187. );
  188. }
  189. export function showToast(
  190. content: string,
  191. action?: ToastProps["action"],
  192. delay = 3000,
  193. ) {
  194. const div = document.createElement("div");
  195. div.className = styles.show;
  196. document.body.appendChild(div);
  197. const root = createRoot(div);
  198. const close = () => {
  199. div.classList.add(styles.hide);
  200. setTimeout(() => {
  201. root.unmount();
  202. div.remove();
  203. }, 300);
  204. };
  205. setTimeout(() => {
  206. close();
  207. }, delay);
  208. root.render(<Toast content={content} action={action} onClose={close} />);
  209. }
  210. export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
  211. autoHeight?: boolean;
  212. rows?: number;
  213. };
  214. export function Input(props: InputProps) {
  215. return (
  216. <textarea
  217. {...props}
  218. className={`${styles["input"]} ${props.className}`}
  219. ></textarea>
  220. );
  221. }
  222. export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  223. const [visible, setVisible] = useState(false);
  224. function changeVisibility() {
  225. setVisible(!visible);
  226. }
  227. return (
  228. <div className={"password-input-container"}>
  229. <IconButton
  230. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  231. onClick={changeVisibility}
  232. className={"password-eye"}
  233. />
  234. <input
  235. {...props}
  236. type={visible ? "text" : "password"}
  237. className={"password-input"}
  238. />
  239. </div>
  240. );
  241. }
  242. export function Select(
  243. props: React.DetailedHTMLProps<
  244. React.SelectHTMLAttributes<HTMLSelectElement>,
  245. HTMLSelectElement
  246. >,
  247. ) {
  248. const { className, children, ...otherProps } = props;
  249. return (
  250. <div className={`${styles["select-with-icon"]} ${className}`}>
  251. <select className={styles["select-with-icon-select"]} {...otherProps}>
  252. {children}
  253. </select>
  254. <DownIcon className={styles["select-with-icon-icon"]} />
  255. </div>
  256. );
  257. }
  258. export function showConfirm(content: any) {
  259. const div = document.createElement("div");
  260. div.className = "modal-mask";
  261. document.body.appendChild(div);
  262. const root = createRoot(div);
  263. const closeModal = () => {
  264. root.unmount();
  265. div.remove();
  266. };
  267. return new Promise<boolean>((resolve) => {
  268. root.render(
  269. <Modal
  270. title={Locale.UI.Confirm}
  271. actions={[
  272. <IconButton
  273. key="cancel"
  274. text={Locale.UI.Cancel}
  275. onClick={() => {
  276. resolve(false);
  277. closeModal();
  278. }}
  279. icon={<CancelIcon />}
  280. tabIndex={0}
  281. bordered
  282. shadow
  283. ></IconButton>,
  284. <IconButton
  285. key="confirm"
  286. text={Locale.UI.Confirm}
  287. type="primary"
  288. onClick={() => {
  289. resolve(true);
  290. closeModal();
  291. }}
  292. icon={<ConfirmIcon />}
  293. tabIndex={0}
  294. autoFocus
  295. bordered
  296. shadow
  297. ></IconButton>,
  298. ]}
  299. onClose={closeModal}
  300. >
  301. {content}
  302. </Modal>,
  303. );
  304. });
  305. }
  306. function PromptInput(props: {
  307. value: string;
  308. onChange: (value: string) => void;
  309. rows?: number;
  310. }) {
  311. const [input, setInput] = useState(props.value);
  312. const onInput = (value: string) => {
  313. props.onChange(value);
  314. setInput(value);
  315. };
  316. return (
  317. <textarea
  318. className={styles["modal-input"]}
  319. autoFocus
  320. value={input}
  321. onInput={(e) => onInput(e.currentTarget.value)}
  322. rows={props.rows ?? 3}
  323. ></textarea>
  324. );
  325. }
  326. export function showPrompt(content: any, value = "", rows = 3) {
  327. const div = document.createElement("div");
  328. div.className = "modal-mask";
  329. document.body.appendChild(div);
  330. const root = createRoot(div);
  331. const closeModal = () => {
  332. root.unmount();
  333. div.remove();
  334. };
  335. return new Promise<string>((resolve) => {
  336. let userInput = "";
  337. root.render(
  338. <Modal
  339. title={content}
  340. actions={[
  341. <IconButton
  342. key="cancel"
  343. text={Locale.UI.Cancel}
  344. onClick={() => {
  345. closeModal();
  346. }}
  347. icon={<CancelIcon />}
  348. bordered
  349. shadow
  350. tabIndex={0}
  351. ></IconButton>,
  352. <IconButton
  353. key="confirm"
  354. text={Locale.UI.Confirm}
  355. type="primary"
  356. onClick={() => {
  357. resolve(userInput);
  358. closeModal();
  359. }}
  360. icon={<ConfirmIcon />}
  361. bordered
  362. shadow
  363. tabIndex={0}
  364. ></IconButton>,
  365. ]}
  366. onClose={closeModal}
  367. >
  368. <PromptInput
  369. onChange={(val) => (userInput = val)}
  370. value={value}
  371. rows={rows}
  372. ></PromptInput>
  373. </Modal>,
  374. );
  375. });
  376. }
  377. export function showImageModal(img: string) {
  378. showModal({
  379. title: Locale.Export.Image.Modal,
  380. children: (
  381. <div>
  382. <img
  383. src={img}
  384. alt="preview"
  385. style={{
  386. maxWidth: "100%",
  387. }}
  388. ></img>
  389. </div>
  390. ),
  391. });
  392. }