ui-lib.tsx 14 KB

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