ui-lib.tsx 14 KB

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