mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			267 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			267 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {
 | 
						|
  useEffect,
 | 
						|
  useState,
 | 
						|
  useRef,
 | 
						|
  useMemo,
 | 
						|
  forwardRef,
 | 
						|
  useImperativeHandle,
 | 
						|
} from "react";
 | 
						|
import { useParams } from "react-router";
 | 
						|
import { IconButton } from "./button";
 | 
						|
import { nanoid } from "nanoid";
 | 
						|
import ExportIcon from "../icons/share.svg";
 | 
						|
import CopyIcon from "../icons/copy.svg";
 | 
						|
import DownloadIcon from "../icons/download.svg";
 | 
						|
import GithubIcon from "../icons/github.svg";
 | 
						|
import LoadingButtonIcon from "../icons/loading.svg";
 | 
						|
import ReloadButtonIcon from "../icons/reload.svg";
 | 
						|
import Locale from "../locales";
 | 
						|
import { Modal, showToast } from "./ui-lib";
 | 
						|
import { copyToClipboard, downloadAs } from "../utils";
 | 
						|
import { Path, ApiPath, REPO_URL } from "@/app/constant";
 | 
						|
import { Loading } from "./home";
 | 
						|
import styles from "./artifacts.module.scss";
 | 
						|
 | 
						|
type HTMLPreviewProps = {
 | 
						|
  code: string;
 | 
						|
  autoHeight?: boolean;
 | 
						|
  height?: number | string;
 | 
						|
  onLoad?: (title?: string) => void;
 | 
						|
};
 | 
						|
 | 
						|
export type HTMLPreviewHander = {
 | 
						|
  reload: () => void;
 | 
						|
};
 | 
						|
 | 
						|
export const HTMLPreview = forwardRef<HTMLPreviewHander, HTMLPreviewProps>(
 | 
						|
  function HTMLPreview(props, ref) {
 | 
						|
    const iframeRef = useRef<HTMLIFrameElement>(null);
 | 
						|
    const [frameId, setFrameId] = useState<string>(nanoid());
 | 
						|
    const [iframeHeight, setIframeHeight] = useState(600);
 | 
						|
    const [title, setTitle] = useState("");
 | 
						|
    /*
 | 
						|
     * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
 | 
						|
     * 1. using srcdoc
 | 
						|
     * 2. using src with dataurl:
 | 
						|
     *    easy to share
 | 
						|
     *    length limit (Data URIs cannot be larger than 32,768 characters.)
 | 
						|
     */
 | 
						|
 | 
						|
    useEffect(() => {
 | 
						|
      const handleMessage = (e: any) => {
 | 
						|
        const { id, height, title } = e.data;
 | 
						|
        setTitle(title);
 | 
						|
        if (id == frameId) {
 | 
						|
          setIframeHeight(height);
 | 
						|
        }
 | 
						|
      };
 | 
						|
      window.addEventListener("message", handleMessage);
 | 
						|
      return () => {
 | 
						|
        window.removeEventListener("message", handleMessage);
 | 
						|
      };
 | 
						|
    }, [frameId]);
 | 
						|
 | 
						|
    useImperativeHandle(ref, () => ({
 | 
						|
      reload: () => {
 | 
						|
        setFrameId(nanoid());
 | 
						|
      },
 | 
						|
    }));
 | 
						|
 | 
						|
    const height = useMemo(() => {
 | 
						|
      if (!props.autoHeight) return props.height || 600;
 | 
						|
      if (typeof props.height === "string") {
 | 
						|
        return props.height;
 | 
						|
      }
 | 
						|
      const parentHeight = props.height || 600;
 | 
						|
      return iframeHeight + 40 > parentHeight
 | 
						|
        ? parentHeight
 | 
						|
        : iframeHeight + 40;
 | 
						|
    }, [props.autoHeight, props.height, iframeHeight]);
 | 
						|
 | 
						|
    const srcDoc = useMemo(() => {
 | 
						|
      const script = `<script>window.addEventListener("DOMContentLoaded", () => new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body))</script>`;
 | 
						|
      if (props.code.includes("<!DOCTYPE html>")) {
 | 
						|
        props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + script);
 | 
						|
      }
 | 
						|
      return script + props.code;
 | 
						|
    }, [props.code, frameId]);
 | 
						|
 | 
						|
    const handleOnLoad = () => {
 | 
						|
      if (props?.onLoad) {
 | 
						|
        props.onLoad(title);
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    return (
 | 
						|
      <iframe
 | 
						|
        className={styles["artifacts-iframe"]}
 | 
						|
        key={frameId}
 | 
						|
        ref={iframeRef}
 | 
						|
        sandbox="allow-forms allow-modals allow-scripts"
 | 
						|
        style={{ height }}
 | 
						|
        srcDoc={srcDoc}
 | 
						|
        onLoad={handleOnLoad}
 | 
						|
      />
 | 
						|
    );
 | 
						|
  },
 | 
						|
);
 | 
						|
 | 
						|
export function ArtifactsShareButton({
 | 
						|
  getCode,
 | 
						|
  id,
 | 
						|
  style,
 | 
						|
  fileName,
 | 
						|
}: {
 | 
						|
  getCode: () => string;
 | 
						|
  id?: string;
 | 
						|
  style?: any;
 | 
						|
  fileName?: string;
 | 
						|
}) {
 | 
						|
  const [loading, setLoading] = useState(false);
 | 
						|
  const [name, setName] = useState(id);
 | 
						|
  const [show, setShow] = useState(false);
 | 
						|
  const shareUrl = useMemo(
 | 
						|
    () => [location.origin, "#", Path.Artifacts, "/", name].join(""),
 | 
						|
    [name],
 | 
						|
  );
 | 
						|
  const upload = (code: string) =>
 | 
						|
    id
 | 
						|
      ? Promise.resolve({ id })
 | 
						|
      : fetch(ApiPath.Artifacts, {
 | 
						|
          method: "POST",
 | 
						|
          body: code,
 | 
						|
        })
 | 
						|
          .then((res) => res.json())
 | 
						|
          .then(({ id }) => {
 | 
						|
            if (id) {
 | 
						|
              return { id };
 | 
						|
            }
 | 
						|
            throw Error();
 | 
						|
          })
 | 
						|
          .catch((e) => {
 | 
						|
            showToast(Locale.Export.Artifacts.Error);
 | 
						|
          });
 | 
						|
  return (
 | 
						|
    <>
 | 
						|
      <div className="window-action-button" style={style}>
 | 
						|
        <IconButton
 | 
						|
          icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
 | 
						|
          bordered
 | 
						|
          title={Locale.Export.Artifacts.Title}
 | 
						|
          onClick={() => {
 | 
						|
            if (loading) return;
 | 
						|
            setLoading(true);
 | 
						|
            upload(getCode())
 | 
						|
              .then((res) => {
 | 
						|
                if (res?.id) {
 | 
						|
                  setShow(true);
 | 
						|
                  setName(res?.id);
 | 
						|
                }
 | 
						|
              })
 | 
						|
              .finally(() => setLoading(false));
 | 
						|
          }}
 | 
						|
        />
 | 
						|
      </div>
 | 
						|
      {show && (
 | 
						|
        <div className="modal-mask">
 | 
						|
          <Modal
 | 
						|
            title={Locale.Export.Artifacts.Title}
 | 
						|
            onClose={() => setShow(false)}
 | 
						|
            actions={[
 | 
						|
              <IconButton
 | 
						|
                key="download"
 | 
						|
                icon={<DownloadIcon />}
 | 
						|
                bordered
 | 
						|
                text={Locale.Export.Download}
 | 
						|
                onClick={() => {
 | 
						|
                  downloadAs(getCode(), `${fileName || name}.html`).then(() =>
 | 
						|
                    setShow(false),
 | 
						|
                  );
 | 
						|
                }}
 | 
						|
              />,
 | 
						|
              <IconButton
 | 
						|
                key="copy"
 | 
						|
                icon={<CopyIcon />}
 | 
						|
                bordered
 | 
						|
                text={Locale.Chat.Actions.Copy}
 | 
						|
                onClick={() => {
 | 
						|
                  copyToClipboard(shareUrl).then(() => setShow(false));
 | 
						|
                }}
 | 
						|
              />,
 | 
						|
            ]}
 | 
						|
          >
 | 
						|
            <div>
 | 
						|
              <a target="_blank" href={shareUrl}>
 | 
						|
                {shareUrl}
 | 
						|
              </a>
 | 
						|
            </div>
 | 
						|
          </Modal>
 | 
						|
        </div>
 | 
						|
      )}
 | 
						|
    </>
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
export function Artifacts() {
 | 
						|
  const { id } = useParams();
 | 
						|
  const [code, setCode] = useState("");
 | 
						|
  const [loading, setLoading] = useState(true);
 | 
						|
  const [fileName, setFileName] = useState("");
 | 
						|
  const previewRef = useRef<HTMLPreviewHander>(null);
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    if (id) {
 | 
						|
      fetch(`${ApiPath.Artifacts}?id=${id}`)
 | 
						|
        .then((res) => {
 | 
						|
          if (res.status > 300) {
 | 
						|
            throw Error("can not get content");
 | 
						|
          }
 | 
						|
          return res;
 | 
						|
        })
 | 
						|
        .then((res) => res.text())
 | 
						|
        .then(setCode)
 | 
						|
        .catch((e) => {
 | 
						|
          showToast(Locale.Export.Artifacts.Error);
 | 
						|
        });
 | 
						|
    }
 | 
						|
  }, [id]);
 | 
						|
 | 
						|
  return (
 | 
						|
    <div className={styles["artifacts"]}>
 | 
						|
      <div className={styles["artifacts-header"]}>
 | 
						|
        <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
 | 
						|
          <IconButton bordered icon={<GithubIcon />} shadow />
 | 
						|
        </a>
 | 
						|
        <IconButton
 | 
						|
          bordered
 | 
						|
          style={{ marginLeft: 20 }}
 | 
						|
          icon={<ReloadButtonIcon />}
 | 
						|
          shadow
 | 
						|
          onClick={() => previewRef.current?.reload()}
 | 
						|
        />
 | 
						|
        <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
 | 
						|
        <ArtifactsShareButton
 | 
						|
          id={id}
 | 
						|
          getCode={() => code}
 | 
						|
          fileName={fileName}
 | 
						|
        />
 | 
						|
      </div>
 | 
						|
      <div className={styles["artifacts-content"]}>
 | 
						|
        {loading && <Loading />}
 | 
						|
        {code && (
 | 
						|
          <HTMLPreview
 | 
						|
            code={code}
 | 
						|
            ref={previewRef}
 | 
						|
            autoHeight={false}
 | 
						|
            height={"100%"}
 | 
						|
            onLoad={(title) => {
 | 
						|
              setFileName(title as string);
 | 
						|
              setLoading(false);
 | 
						|
            }}
 | 
						|
          />
 | 
						|
        )}
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
}
 |