artifacts.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { useEffect, useState, useRef, useMemo } from "react";
  2. import { useParams } from "react-router";
  3. import { useWindowSize } from "@/app/utils";
  4. import { IconButton } from "./button";
  5. import { nanoid } from "nanoid";
  6. import ExportIcon from "../icons/share.svg";
  7. import CopyIcon from "../icons/copy.svg";
  8. import DownloadIcon from "../icons/download.svg";
  9. import GithubIcon from "../icons/github.svg";
  10. import LoadingButtonIcon from "../icons/loading.svg";
  11. import Locale from "../locales";
  12. import { Modal, showToast } from "./ui-lib";
  13. import { copyToClipboard, downloadAs } from "../utils";
  14. import { Path, ApiPath, REPO_URL } from "@/app/constant";
  15. import { Loading } from "./home";
  16. import styles from "./artifacts.module.scss";
  17. export function HTMLPreview(props: {
  18. code: string;
  19. autoHeight?: boolean;
  20. height?: number | string;
  21. onLoad?: (title?: string) => void;
  22. }) {
  23. const ref = useRef<HTMLIFrameElement>(null);
  24. const frameId = useRef<string>(nanoid());
  25. const [iframeHeight, setIframeHeight] = useState(600);
  26. const [title, setTitle] = useState("");
  27. /*
  28. * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
  29. * 1. using srcdoc
  30. * 2. using src with dataurl:
  31. * easy to share
  32. * length limit (Data URIs cannot be larger than 32,768 characters.)
  33. */
  34. useEffect(() => {
  35. const handleMessage = (e: any) => {
  36. const { id, height, title } = e.data;
  37. setTitle(title);
  38. if (id == frameId.current) {
  39. setIframeHeight(height);
  40. }
  41. };
  42. window.addEventListener("message", handleMessage);
  43. return () => {
  44. window.removeEventListener("message", handleMessage);
  45. };
  46. }, []);
  47. const height = useMemo(() => {
  48. if (!props.autoHeight) return props.height || 600;
  49. if (typeof props.height === "string") {
  50. return props.height;
  51. }
  52. const parentHeight = props.height || 600;
  53. return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40;
  54. }, [props.autoHeight, props.height, iframeHeight]);
  55. const srcDoc = useMemo(() => {
  56. const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
  57. if (props.code.includes("</head>")) {
  58. props.code.replace("</head>", "</head>" + script);
  59. }
  60. return props.code + script;
  61. }, [props.code]);
  62. const handleOnLoad = () => {
  63. if (props?.onLoad) {
  64. props.onLoad(title);
  65. }
  66. };
  67. return (
  68. <iframe
  69. className={styles["artifacts-iframe"]}
  70. id={frameId.current}
  71. ref={ref}
  72. sandbox="allow-forms allow-modals allow-scripts"
  73. style={{ height }}
  74. srcDoc={srcDoc}
  75. onLoad={handleOnLoad}
  76. />
  77. );
  78. }
  79. export function ArtifactsShareButton({
  80. getCode,
  81. id,
  82. style,
  83. fileName,
  84. }: {
  85. getCode: () => string;
  86. id?: string;
  87. style?: any;
  88. fileName?: string;
  89. }) {
  90. const [loading, setLoading] = useState(false);
  91. const [name, setName] = useState(id);
  92. const [show, setShow] = useState(false);
  93. const shareUrl = useMemo(
  94. () => [location.origin, "#", Path.Artifacts, "/", name].join(""),
  95. [name],
  96. );
  97. const upload = (code: string) =>
  98. id
  99. ? Promise.resolve({ id })
  100. : fetch(ApiPath.Artifacts, {
  101. method: "POST",
  102. body: code,
  103. })
  104. .then((res) => res.json())
  105. .then(({ id }) => {
  106. if (id) {
  107. return { id };
  108. }
  109. throw Error();
  110. })
  111. .catch((e) => {
  112. showToast(Locale.Export.Artifacts.Error);
  113. });
  114. return (
  115. <>
  116. <div className="window-action-button" style={style}>
  117. <IconButton
  118. icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
  119. bordered
  120. title={Locale.Export.Artifacts.Title}
  121. onClick={() => {
  122. if (loading) return;
  123. setLoading(true);
  124. upload(getCode())
  125. .then((res) => {
  126. if (res?.id) {
  127. setShow(true);
  128. setName(res?.id);
  129. }
  130. })
  131. .finally(() => setLoading(false));
  132. }}
  133. />
  134. </div>
  135. {show && (
  136. <div className="modal-mask">
  137. <Modal
  138. title={Locale.Export.Artifacts.Title}
  139. onClose={() => setShow(false)}
  140. actions={[
  141. <IconButton
  142. key="download"
  143. icon={<DownloadIcon />}
  144. bordered
  145. text={Locale.Export.Download}
  146. onClick={() => {
  147. downloadAs(getCode(), `${fileName || name}.html`).then(() =>
  148. setShow(false),
  149. );
  150. }}
  151. />,
  152. <IconButton
  153. key="copy"
  154. icon={<CopyIcon />}
  155. bordered
  156. text={Locale.Chat.Actions.Copy}
  157. onClick={() => {
  158. copyToClipboard(shareUrl).then(() => setShow(false));
  159. }}
  160. />,
  161. ]}
  162. >
  163. <div>
  164. <a target="_blank" href={shareUrl}>
  165. {shareUrl}
  166. </a>
  167. </div>
  168. </Modal>
  169. </div>
  170. )}
  171. </>
  172. );
  173. }
  174. export function Artifacts() {
  175. const { id } = useParams();
  176. const [code, setCode] = useState("");
  177. const [loading, setLoading] = useState(true);
  178. const [fileName, setFileName] = useState("");
  179. useEffect(() => {
  180. if (id) {
  181. fetch(`${ApiPath.Artifacts}?id=${id}`)
  182. .then((res) => {
  183. if (res.status > 300) {
  184. throw Error("can not get content");
  185. }
  186. return res;
  187. })
  188. .then((res) => res.text())
  189. .then(setCode)
  190. .catch((e) => {
  191. showToast(Locale.Export.Artifacts.Error);
  192. });
  193. }
  194. }, [id]);
  195. return (
  196. <div className={styles["artifacts"]}>
  197. <div className={styles["artifacts-header"]}>
  198. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  199. <IconButton bordered icon={<GithubIcon />} shadow />
  200. </a>
  201. <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
  202. <ArtifactsShareButton
  203. id={id}
  204. getCode={() => code}
  205. fileName={fileName}
  206. />
  207. </div>
  208. <div className={styles["artifacts-content"]}>
  209. {loading && <Loading />}
  210. {code && (
  211. <HTMLPreview
  212. code={code}
  213. autoHeight={false}
  214. height={"100%"}
  215. onLoad={(title) => {
  216. setFileName(title as string);
  217. setLoading(false);
  218. }}
  219. />
  220. )}
  221. </div>
  222. </div>
  223. );
  224. }