ui-lib.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  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, MouseEvent, 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-mask"]} onClick={props.onClose}></div>
  27. )}
  28. {props.open && (
  29. <div className={styles["popover-content"]}>{props.content}</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. onClick?: (event: MouseEvent) => void;
  46. vertical?: boolean;
  47. }) {
  48. return (
  49. <div
  50. className={
  51. styles["list-item"] +
  52. ` ${props.vertical ? styles["vertical"] : ""} ` +
  53. ` ${props.className || ""}`
  54. }
  55. onClick={props.onClick}
  56. >
  57. <div className={styles["list-header"]}>
  58. {props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
  59. <div className={styles["list-item-title"]}>
  60. <div>{props.title}</div>
  61. {props.subTitle && (
  62. <div className={styles["list-item-sub-title"]}>
  63. {props.subTitle}
  64. </div>
  65. )}
  66. </div>
  67. </div>
  68. {props.children}
  69. </div>
  70. );
  71. }
  72. export function List(props: { children: React.ReactNode; id?: string }) {
  73. return (
  74. <div className={styles.list} id={props.id}>
  75. {props.children}
  76. </div>
  77. );
  78. }
  79. export function Loading() {
  80. return (
  81. <div
  82. style={{
  83. height: "100vh",
  84. width: "100vw",
  85. display: "flex",
  86. alignItems: "center",
  87. justifyContent: "center",
  88. }}
  89. >
  90. <LoadingIcon />
  91. </div>
  92. );
  93. }
  94. interface ModalProps {
  95. title: string;
  96. children?: any;
  97. actions?: React.ReactNode[];
  98. defaultMax?: boolean;
  99. footer?: React.ReactNode;
  100. onClose?: () => void;
  101. }
  102. export function Modal(props: ModalProps) {
  103. useEffect(() => {
  104. const onKeyDown = (e: KeyboardEvent) => {
  105. if (e.key === "Escape") {
  106. props.onClose?.();
  107. }
  108. };
  109. window.addEventListener("keydown", onKeyDown);
  110. return () => {
  111. window.removeEventListener("keydown", onKeyDown);
  112. };
  113. // eslint-disable-next-line react-hooks/exhaustive-deps
  114. }, []);
  115. const [isMax, setMax] = useState(!!props.defaultMax);
  116. return (
  117. <div
  118. className={
  119. styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
  120. }
  121. >
  122. <div className={styles["modal-header"]}>
  123. <div className={styles["modal-title"]}>{props.title}</div>
  124. <div className={styles["modal-header-actions"]}>
  125. <div
  126. className={styles["modal-header-action"]}
  127. onClick={() => setMax(!isMax)}
  128. >
  129. {isMax ? <MinIcon /> : <MaxIcon />}
  130. </div>
  131. <div
  132. className={styles["modal-header-action"]}
  133. onClick={props.onClose}
  134. >
  135. <CloseIcon />
  136. </div>
  137. </div>
  138. </div>
  139. <div className={styles["modal-content"]}>{props.children}</div>
  140. <div className={styles["modal-footer"]}>
  141. {props.footer}
  142. <div className={styles["modal-actions"]}>
  143. {props.actions?.map((action, i) => (
  144. <div key={i} className={styles["modal-action"]}>
  145. {action}
  146. </div>
  147. ))}
  148. </div>
  149. </div>
  150. </div>
  151. );
  152. }
  153. export function showModal(props: ModalProps) {
  154. const div = document.createElement("div");
  155. div.className = "modal-mask";
  156. document.body.appendChild(div);
  157. const root = createRoot(div);
  158. const closeModal = () => {
  159. props.onClose?.();
  160. root.unmount();
  161. div.remove();
  162. };
  163. div.onclick = (e) => {
  164. if (e.target === div) {
  165. closeModal();
  166. }
  167. };
  168. root.render(<Modal {...props} onClose={closeModal}></Modal>);
  169. }
  170. export type ToastProps = {
  171. content: string;
  172. action?: {
  173. text: string;
  174. onClick: () => void;
  175. };
  176. onClose?: () => void;
  177. };
  178. export function Toast(props: ToastProps) {
  179. return (
  180. <div className={styles["toast-container"]}>
  181. <div className={styles["toast-content"]}>
  182. <span>{props.content}</span>
  183. {props.action && (
  184. <button
  185. onClick={() => {
  186. props.action?.onClick?.();
  187. props.onClose?.();
  188. }}
  189. className={styles["toast-action"]}
  190. >
  191. {props.action.text}
  192. </button>
  193. )}
  194. </div>
  195. </div>
  196. );
  197. }
  198. export function showToast(
  199. content: string,
  200. action?: ToastProps["action"],
  201. delay = 3000,
  202. ) {
  203. const div = document.createElement("div");
  204. div.className = styles.show;
  205. document.body.appendChild(div);
  206. const root = createRoot(div);
  207. const close = () => {
  208. div.classList.add(styles.hide);
  209. setTimeout(() => {
  210. root.unmount();
  211. div.remove();
  212. }, 300);
  213. };
  214. setTimeout(() => {
  215. close();
  216. }, delay);
  217. root.render(<Toast content={content} action={action} onClose={close} />);
  218. }
  219. export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
  220. autoHeight?: boolean;
  221. rows?: number;
  222. };
  223. export function Input(props: InputProps) {
  224. return (
  225. <textarea
  226. {...props}
  227. className={`${styles["input"]} ${props.className}`}
  228. ></textarea>
  229. );
  230. }
  231. export function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  232. const [visible, setVisible] = useState(false);
  233. function changeVisibility() {
  234. setVisible(!visible);
  235. }
  236. return (
  237. <div className={"password-input-container"}>
  238. <IconButton
  239. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  240. onClick={changeVisibility}
  241. className={"password-eye"}
  242. />
  243. <input
  244. {...props}
  245. type={visible ? "text" : "password"}
  246. className={"password-input"}
  247. />
  248. </div>
  249. );
  250. }
  251. export function Select(
  252. props: React.DetailedHTMLProps<
  253. React.SelectHTMLAttributes<HTMLSelectElement>,
  254. HTMLSelectElement
  255. >,
  256. ) {
  257. const { className, children, ...otherProps } = props;
  258. return (
  259. <div className={`${styles["select-with-icon"]} ${className}`}>
  260. <select className={styles["select-with-icon-select"]} {...otherProps}>
  261. {children}
  262. </select>
  263. <DownIcon className={styles["select-with-icon-icon"]} />
  264. </div>
  265. );
  266. }
  267. export function showConfirm(content: any) {
  268. const div = document.createElement("div");
  269. div.className = "modal-mask";
  270. document.body.appendChild(div);
  271. const root = createRoot(div);
  272. const closeModal = () => {
  273. root.unmount();
  274. div.remove();
  275. };
  276. return new Promise<boolean>((resolve) => {
  277. root.render(
  278. <Modal
  279. title={Locale.UI.Confirm}
  280. actions={[
  281. <IconButton
  282. key="cancel"
  283. text={Locale.UI.Cancel}
  284. onClick={() => {
  285. resolve(false);
  286. closeModal();
  287. }}
  288. icon={<CancelIcon />}
  289. tabIndex={0}
  290. bordered
  291. shadow
  292. ></IconButton>,
  293. <IconButton
  294. key="confirm"
  295. text={Locale.UI.Confirm}
  296. type="primary"
  297. onClick={() => {
  298. resolve(true);
  299. closeModal();
  300. }}
  301. icon={<ConfirmIcon />}
  302. tabIndex={0}
  303. autoFocus
  304. bordered
  305. shadow
  306. ></IconButton>,
  307. ]}
  308. onClose={closeModal}
  309. >
  310. {content}
  311. </Modal>,
  312. );
  313. });
  314. }
  315. function PromptInput(props: {
  316. value: string;
  317. onChange: (value: string) => void;
  318. rows?: number;
  319. }) {
  320. const [input, setInput] = useState(props.value);
  321. const onInput = (value: string) => {
  322. props.onChange(value);
  323. setInput(value);
  324. };
  325. return (
  326. <textarea
  327. className={styles["modal-input"]}
  328. autoFocus
  329. value={input}
  330. onInput={(e) => onInput(e.currentTarget.value)}
  331. rows={props.rows ?? 3}
  332. ></textarea>
  333. );
  334. }
  335. export function showPrompt(content: any, value = "", rows = 3) {
  336. const div = document.createElement("div");
  337. div.className = "modal-mask";
  338. document.body.appendChild(div);
  339. const root = createRoot(div);
  340. const closeModal = () => {
  341. root.unmount();
  342. div.remove();
  343. };
  344. return new Promise<string>((resolve) => {
  345. let userInput = value;
  346. root.render(
  347. <Modal
  348. title={content}
  349. actions={[
  350. <IconButton
  351. key="cancel"
  352. text={Locale.UI.Cancel}
  353. onClick={() => {
  354. closeModal();
  355. }}
  356. icon={<CancelIcon />}
  357. bordered
  358. shadow
  359. tabIndex={0}
  360. ></IconButton>,
  361. <IconButton
  362. key="confirm"
  363. text={Locale.UI.Confirm}
  364. type="primary"
  365. onClick={() => {
  366. resolve(userInput);
  367. closeModal();
  368. }}
  369. icon={<ConfirmIcon />}
  370. bordered
  371. shadow
  372. tabIndex={0}
  373. ></IconButton>,
  374. ]}
  375. onClose={closeModal}
  376. >
  377. <PromptInput
  378. onChange={(val) => (userInput = val)}
  379. value={value}
  380. rows={rows}
  381. ></PromptInput>
  382. </Modal>,
  383. );
  384. });
  385. }
  386. export function showImageModal(img: string) {
  387. showModal({
  388. title: Locale.Export.Image.Modal,
  389. children: (
  390. <div>
  391. <img
  392. src={img}
  393. alt="preview"
  394. style={{
  395. maxWidth: "100%",
  396. }}
  397. ></img>
  398. </div>
  399. ),
  400. });
  401. }
  402. export function Selector<T>(props: {
  403. items: Array<{
  404. title: string;
  405. subTitle?: string;
  406. value: T;
  407. disable?: boolean;
  408. }>;
  409. defaultSelectedValue?: T;
  410. onSelection?: (selection: T[]) => void;
  411. onClose?: () => void;
  412. multiple?: boolean;
  413. }) {
  414. return (
  415. <div className={styles["selector"]} onClick={() => props.onClose?.()}>
  416. <div className={styles["selector-content"]}>
  417. <List>
  418. {props.items.map((item, i) => {
  419. const selected = props.defaultSelectedValue === item.value;
  420. return (
  421. <ListItem
  422. className={`${styles["selector-item"]} ${
  423. item.disable && styles["selector-item-disabled"]
  424. }`}
  425. key={i}
  426. title={item.title}
  427. subTitle={item.subTitle}
  428. onClick={(event) => {
  429. event.stopPropagation();
  430. if (!item.disable) {
  431. props.onSelection?.([item.value]);
  432. props.onClose?.();
  433. }
  434. }}
  435. >
  436. {selected ? (
  437. <div
  438. style={{
  439. height: 10,
  440. width: 10,
  441. backgroundColor: "var(--primary)",
  442. borderRadius: 10,
  443. }}
  444. ></div>
  445. ) : (
  446. <></>
  447. )}
  448. </ListItem>
  449. );
  450. })}
  451. </List>
  452. </div>
  453. </div>
  454. );
  455. }