ui-lib.tsx 11 KB

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