ui-lib.tsx 12 KB

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