ui-lib.tsx 11 KB

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