ui-lib.tsx 12 KB

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