ui-lib.tsx 12 KB

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