ui-lib.tsx 14 KB

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