Skip to content

Commit

Permalink
save artifact content to cloudflare workers kv
Browse files Browse the repository at this point in the history
  • Loading branch information
lloydzhou committed Jul 24, 2024
1 parent 1ecefd8 commit 421bf33
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 52 deletions.
54 changes: 54 additions & 0 deletions app/api/artifact/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import md5 from "spark-md5";
import { NextRequest, NextResponse } from "next/server";
import { getServerSideConfig } from "@/app/config/server";

async function handle(req: NextRequest, res: NextResponse) {
const serverConfig = getServerSideConfig();
const storeUrl = (key) =>
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}/values/${key}`;
const storeHeaders = () => ({
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
});
if (req.method === "POST") {
const clonedBody = await req.text();
const hashedCode = md5.hash(clonedBody).trim();
const res = await fetch(storeUrl(hashedCode), {
headers: storeHeaders(),
method: "PUT",
body: clonedBody,
});
const result = await res.json();
console.log("save data", result);
if (result?.success) {
return NextResponse.json(
{ code: 0, id: hashedCode, result },
{ status: res.status },
);
}
return NextResponse.json(
{ error: true, msg: "Save data error" },
{ status: 400 },
);
}
if (req.method === "GET") {
const id = req?.nextUrl?.searchParams?.get("id");
const res = await fetch(storeUrl(id), {
headers: storeHeaders(),
method: "GET",
});
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
return NextResponse.json(
{ error: true, msg: "Invalid request" },
{ status: 400 },
);
}

export const POST = handle;
export const GET = handle;

export const runtime = "edge";
190 changes: 190 additions & 0 deletions app/components/artifact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useEffect, useState, useRef, useMemo } from "react";
import { useParams } from "react-router";
import { useWindowSize } from "@/app/utils";
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 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";

export function HTMLPreview(props: {
code: string;
autoHeight?: boolean;
height?: number;
}) {
const ref = useRef<HTMLIFrameElement>(null);
const frameId = useRef<string>(nanoid());
const [iframeHeight, setIframeHeight] = useState(600);
/*
* 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(() => {
window.addEventListener("message", (e) => {
const { id, height } = e.data;
if (id == frameId.current) {
console.log("setHeight", height);
setIframeHeight(height);
}
});
}, []);

const height = useMemo(() => {
const parentHeight = props.height || 600;
if (props.autoHeight !== false) {
return iframeHeight > parentHeight ? parentHeight : iframeHeight + 40;
} else {
return parentHeight;
}
}, [props.autoHeight, props.height, iframeHeight]);

const srcDoc = useMemo(() => {
const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
if (props.code.includes("</head>")) {
props.code.replace("</head>", "</head>" + script);
}
return props.code + script;
}, [props.code]);

return (
<iframe
id={frameId.current}
ref={ref}
frameBorder={0}
sandbox="allow-forms allow-modals allow-scripts"
style={{ width: "100%", height }}
// src={`data:text/html,${encodeURIComponent(srcDoc)}`}
srcDoc={srcDoc}
></iframe>
);
}

export function ArtifactShareButton({ getCode, id, style }) {
const [name, setName] = useState(id);
const [show, setShow] = useState(false);
const shareUrl = useMemo(() =>
[location.origin, "#", Path.Artifact, "/", name].join(""),
);
const upload = (code) =>
fetch(ApiPath.Artifact, {
method: "POST",
body: getCode(),
})
.then((res) => res.json())
.then(({ id }) => {
if (id) {
setShow(true);
return setName(id);
}
throw Error();
})
.catch((e) => {
showToast(Locale.Export.Artifact.Error);
});
return (
<>
<div className="window-action-button" style={style}>
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Export.Artifact.Title}
onClick={() => {
upload(getCode());
}}
/>
</div>
{show && (
<div className="modal-mask">
<Modal
title={Locale.Export.Artifact.Title}
onClose={() => setShow(false)}
actions={[
<IconButton
key="download"
icon={<DownloadIcon />}
bordered
text={Locale.Export.Download}
onClick={() => {
downloadAs(getCode(), `${id}.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 Artifact() {
const { id } = useParams();
const [code, setCode] = useState("");
const { height } = useWindowSize();

useEffect(() => {
if (id) {
fetch(`${ApiPath.Artifact}?id=${id}`)
.then((res) => res.text())
.then(setCode);
}
}, [id]);

return (
<div
style={{
disply: "block",
width: "100%",
height: "100%",
position: "relative",
}}
>
<div
style={{
height: 40,
display: "flex",
alignItems: "center",
padding: 12,
}}
>
<div style={{ flex: 1 }}>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<IconButton bordered icon={<GithubIcon />} shadow />
</a>
</div>
<ArtifactShareButton id={id} getCode={() => code} />
</div>
{code ? (
<HTMLPreview code={code} autoHeight={false} height={height - 40} />
) : (
<Loading />
)}
</div>
);
}
13 changes: 13 additions & 0 deletions app/components/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
);
}

const Artifact = dynamic(async () => (await import("./artifact")).Artifact, {
loading: () => <Loading noLogo />,
});

const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
Expand Down Expand Up @@ -125,6 +129,7 @@ const loadAsyncGoogleFont = () => {
function Screen() {
const config = useAppConfig();
const location = useLocation();
const isArtifact = location.pathname.includes(Path.Artifact);
const isHome = location.pathname === Path.Home;
const isAuth = location.pathname === Path.Auth;
const isMobileScreen = useMobileScreen();
Expand All @@ -135,6 +140,14 @@ function Screen() {
loadAsyncGoogleFont();
}, []);

if (isArtifact) {
return (
<Routes>
<Route exact path="/artifact/:id" element={<Artifact />} />
</Routes>
);
}

return (
<div
className={
Expand Down
69 changes: 17 additions & 52 deletions app/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
import { showImageModal } from "./ui-lib";
import { nanoid } from "nanoid";
import { ArtifactShareButton, HTMLPreview } from "./artifact";

export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -61,56 +61,6 @@ export function Mermaid(props: { code: string }) {
);
}

export function HTMLPreview(props: { code: string }) {
const ref = useRef<HTMLIFrameElement>(null);
const frameId = useRef<string>(nanoid());
const [height, setHeight] = useState(600);
/*
* 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(() => {
window.addEventListener("message", (e) => {
const { id, height } = e.data;
if (id == frameId.current) {
console.log("setHeight", height);
if (height < 600) {
setHeight(height + 40);
}
}
});
}, []);

const script = encodeURIComponent(
`<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`,
);

return (
<div
className="no-dark html"
style={{
cursor: "pointer",
overflow: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<iframe
id={frameId.current}
ref={ref}
frameBorder={0}
sandbox="allow-forms allow-modals allow-scripts"
style={{ width: "100%", height }}
src={`data:text/html,${encodeURIComponent(props.code)}${script}`}
// srcDoc={props.code + script}
></iframe>
</div>
);
}

export function PreCode(props: { children: any }) {
const ref = useRef<HTMLPreElement>(null);
const refText = ref.current?.innerText;
Expand Down Expand Up @@ -151,7 +101,22 @@ export function PreCode(props: { children: any }) {
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{htmlCode.length > 0 && <HTMLPreview code={htmlCode} key={htmlCode} />}
{htmlCode.length > 0 && (
<div
className="no-dark html"
style={{
overflow: "auto",
position: "relative",
}}
onClick={(e) => e.stopPropagation()}
>
<ArtifactShareButton
style={{ position: "absolute", right: 10, top: 10 }}
getCode={() => htmlCode}
/>
<HTMLPreview code={htmlCode} />
</div>
)}
</>
);
}
Expand Down
4 changes: 4 additions & 0 deletions app/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ export const getServerSideConfig = () => {
alibabaUrl: process.env.ALIBABA_URL,
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),

cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),

gtmId: process.env.GTM_ID,

needCode: ACCESS_CODES.size > 0,
Expand Down
Loading

0 comments on commit 421bf33

Please sign in to comment.