Skip to content

Commit 421bf33

Browse files
committed
save artifact content to cloudflare workers kv
1 parent 1ecefd8 commit 421bf33

File tree

8 files changed

+289
-52
lines changed

8 files changed

+289
-52
lines changed

app/api/artifact/route.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import md5 from "spark-md5";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { getServerSideConfig } from "@/app/config/server";
4+
5+
async function handle(req: NextRequest, res: NextResponse) {
6+
const serverConfig = getServerSideConfig();
7+
const storeUrl = (key) =>
8+
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}/values/${key}`;
9+
const storeHeaders = () => ({
10+
Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
11+
});
12+
if (req.method === "POST") {
13+
const clonedBody = await req.text();
14+
const hashedCode = md5.hash(clonedBody).trim();
15+
const res = await fetch(storeUrl(hashedCode), {
16+
headers: storeHeaders(),
17+
method: "PUT",
18+
body: clonedBody,
19+
});
20+
const result = await res.json();
21+
console.log("save data", result);
22+
if (result?.success) {
23+
return NextResponse.json(
24+
{ code: 0, id: hashedCode, result },
25+
{ status: res.status },
26+
);
27+
}
28+
return NextResponse.json(
29+
{ error: true, msg: "Save data error" },
30+
{ status: 400 },
31+
);
32+
}
33+
if (req.method === "GET") {
34+
const id = req?.nextUrl?.searchParams?.get("id");
35+
const res = await fetch(storeUrl(id), {
36+
headers: storeHeaders(),
37+
method: "GET",
38+
});
39+
return new Response(res.body, {
40+
status: res.status,
41+
statusText: res.statusText,
42+
headers: res.headers,
43+
});
44+
}
45+
return NextResponse.json(
46+
{ error: true, msg: "Invalid request" },
47+
{ status: 400 },
48+
);
49+
}
50+
51+
export const POST = handle;
52+
export const GET = handle;
53+
54+
export const runtime = "edge";

app/components/artifact.tsx

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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 Locale from "../locales";
11+
import { Modal, showToast } from "./ui-lib";
12+
import { copyToClipboard, downloadAs } from "../utils";
13+
import { Path, ApiPath, REPO_URL } from "@/app/constant";
14+
import { Loading } from "./home";
15+
16+
export function HTMLPreview(props: {
17+
code: string;
18+
autoHeight?: boolean;
19+
height?: number;
20+
}) {
21+
const ref = useRef<HTMLIFrameElement>(null);
22+
const frameId = useRef<string>(nanoid());
23+
const [iframeHeight, setIframeHeight] = useState(600);
24+
/*
25+
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
26+
* 1. using srcdoc
27+
* 2. using src with dataurl:
28+
* easy to share
29+
* length limit (Data URIs cannot be larger than 32,768 characters.)
30+
*/
31+
32+
useEffect(() => {
33+
window.addEventListener("message", (e) => {
34+
const { id, height } = e.data;
35+
if (id == frameId.current) {
36+
console.log("setHeight", height);
37+
setIframeHeight(height);
38+
}
39+
});
40+
}, []);
41+
42+
const height = useMemo(() => {
43+
const parentHeight = props.height || 600;
44+
if (props.autoHeight !== false) {
45+
return iframeHeight > parentHeight ? parentHeight : iframeHeight + 40;
46+
} else {
47+
return parentHeight;
48+
}
49+
}, [props.autoHeight, props.height, iframeHeight]);
50+
51+
const srcDoc = useMemo(() => {
52+
const script = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
53+
if (props.code.includes("</head>")) {
54+
props.code.replace("</head>", "</head>" + script);
55+
}
56+
return props.code + script;
57+
}, [props.code]);
58+
59+
return (
60+
<iframe
61+
id={frameId.current}
62+
ref={ref}
63+
frameBorder={0}
64+
sandbox="allow-forms allow-modals allow-scripts"
65+
style={{ width: "100%", height }}
66+
// src={`data:text/html,${encodeURIComponent(srcDoc)}`}
67+
srcDoc={srcDoc}
68+
></iframe>
69+
);
70+
}
71+
72+
export function ArtifactShareButton({ getCode, id, style }) {
73+
const [name, setName] = useState(id);
74+
const [show, setShow] = useState(false);
75+
const shareUrl = useMemo(() =>
76+
[location.origin, "#", Path.Artifact, "/", name].join(""),
77+
);
78+
const upload = (code) =>
79+
fetch(ApiPath.Artifact, {
80+
method: "POST",
81+
body: getCode(),
82+
})
83+
.then((res) => res.json())
84+
.then(({ id }) => {
85+
if (id) {
86+
setShow(true);
87+
return setName(id);
88+
}
89+
throw Error();
90+
})
91+
.catch((e) => {
92+
showToast(Locale.Export.Artifact.Error);
93+
});
94+
return (
95+
<>
96+
<div className="window-action-button" style={style}>
97+
<IconButton
98+
icon={<ExportIcon />}
99+
bordered
100+
title={Locale.Export.Artifact.Title}
101+
onClick={() => {
102+
upload(getCode());
103+
}}
104+
/>
105+
</div>
106+
{show && (
107+
<div className="modal-mask">
108+
<Modal
109+
title={Locale.Export.Artifact.Title}
110+
onClose={() => setShow(false)}
111+
actions={[
112+
<IconButton
113+
key="download"
114+
icon={<DownloadIcon />}
115+
bordered
116+
text={Locale.Export.Download}
117+
onClick={() => {
118+
downloadAs(getCode(), `${id}.html`).then(() =>
119+
setShow(false),
120+
);
121+
}}
122+
/>,
123+
<IconButton
124+
key="copy"
125+
icon={<CopyIcon />}
126+
bordered
127+
text={Locale.Chat.Actions.Copy}
128+
onClick={() => {
129+
copyToClipboard(shareUrl).then(() => setShow(false));
130+
}}
131+
/>,
132+
]}
133+
>
134+
<div>
135+
<a target="_blank" href={shareUrl}>
136+
{shareUrl}
137+
</a>
138+
</div>
139+
</Modal>
140+
</div>
141+
)}
142+
</>
143+
);
144+
}
145+
146+
export function Artifact() {
147+
const { id } = useParams();
148+
const [code, setCode] = useState("");
149+
const { height } = useWindowSize();
150+
151+
useEffect(() => {
152+
if (id) {
153+
fetch(`${ApiPath.Artifact}?id=${id}`)
154+
.then((res) => res.text())
155+
.then(setCode);
156+
}
157+
}, [id]);
158+
159+
return (
160+
<div
161+
style={{
162+
disply: "block",
163+
width: "100%",
164+
height: "100%",
165+
position: "relative",
166+
}}
167+
>
168+
<div
169+
style={{
170+
height: 40,
171+
display: "flex",
172+
alignItems: "center",
173+
padding: 12,
174+
}}
175+
>
176+
<div style={{ flex: 1 }}>
177+
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
178+
<IconButton bordered icon={<GithubIcon />} shadow />
179+
</a>
180+
</div>
181+
<ArtifactShareButton id={id} getCode={() => code} />
182+
</div>
183+
{code ? (
184+
<HTMLPreview code={code} autoHeight={false} height={height - 40} />
185+
) : (
186+
<Loading />
187+
)}
188+
</div>
189+
);
190+
}

app/components/home.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
3939
);
4040
}
4141

42+
const Artifact = dynamic(async () => (await import("./artifact")).Artifact, {
43+
loading: () => <Loading noLogo />,
44+
});
45+
4246
const Settings = dynamic(async () => (await import("./settings")).Settings, {
4347
loading: () => <Loading noLogo />,
4448
});
@@ -125,6 +129,7 @@ const loadAsyncGoogleFont = () => {
125129
function Screen() {
126130
const config = useAppConfig();
127131
const location = useLocation();
132+
const isArtifact = location.pathname.includes(Path.Artifact);
128133
const isHome = location.pathname === Path.Home;
129134
const isAuth = location.pathname === Path.Auth;
130135
const isMobileScreen = useMobileScreen();
@@ -135,6 +140,14 @@ function Screen() {
135140
loadAsyncGoogleFont();
136141
}, []);
137142

143+
if (isArtifact) {
144+
return (
145+
<Routes>
146+
<Route exact path="/artifact/:id" element={<Artifact />} />
147+
</Routes>
148+
);
149+
}
150+
138151
return (
139152
<div
140153
className={

app/components/markdown.tsx

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import LoadingIcon from "../icons/three-dots.svg";
1313
import React from "react";
1414
import { useDebouncedCallback } from "use-debounce";
1515
import { showImageModal } from "./ui-lib";
16-
import { nanoid } from "nanoid";
16+
import { ArtifactShareButton, HTMLPreview } from "./artifact";
1717

1818
export function Mermaid(props: { code: string }) {
1919
const ref = useRef<HTMLDivElement>(null);
@@ -61,56 +61,6 @@ export function Mermaid(props: { code: string }) {
6161
);
6262
}
6363

64-
export function HTMLPreview(props: { code: string }) {
65-
const ref = useRef<HTMLIFrameElement>(null);
66-
const frameId = useRef<string>(nanoid());
67-
const [height, setHeight] = useState(600);
68-
/*
69-
* https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an
70-
* 1. using srcdoc
71-
* 2. using src with dataurl:
72-
* easy to share
73-
* length limit (Data URIs cannot be larger than 32,768 characters.)
74-
*/
75-
76-
useEffect(() => {
77-
window.addEventListener("message", (e) => {
78-
const { id, height } = e.data;
79-
if (id == frameId.current) {
80-
console.log("setHeight", height);
81-
if (height < 600) {
82-
setHeight(height + 40);
83-
}
84-
}
85-
});
86-
}, []);
87-
88-
const script = encodeURIComponent(
89-
`<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`,
90-
);
91-
92-
return (
93-
<div
94-
className="no-dark html"
95-
style={{
96-
cursor: "pointer",
97-
overflow: "auto",
98-
}}
99-
onClick={(e) => e.stopPropagation()}
100-
>
101-
<iframe
102-
id={frameId.current}
103-
ref={ref}
104-
frameBorder={0}
105-
sandbox="allow-forms allow-modals allow-scripts"
106-
style={{ width: "100%", height }}
107-
src={`data:text/html,${encodeURIComponent(props.code)}${script}`}
108-
// srcDoc={props.code + script}
109-
></iframe>
110-
</div>
111-
);
112-
}
113-
11464
export function PreCode(props: { children: any }) {
11565
const ref = useRef<HTMLPreElement>(null);
11666
const refText = ref.current?.innerText;
@@ -151,7 +101,22 @@ export function PreCode(props: { children: any }) {
151101
{mermaidCode.length > 0 && (
152102
<Mermaid code={mermaidCode} key={mermaidCode} />
153103
)}
154-
{htmlCode.length > 0 && <HTMLPreview code={htmlCode} key={htmlCode} />}
104+
{htmlCode.length > 0 && (
105+
<div
106+
className="no-dark html"
107+
style={{
108+
overflow: "auto",
109+
position: "relative",
110+
}}
111+
onClick={(e) => e.stopPropagation()}
112+
>
113+
<ArtifactShareButton
114+
style={{ position: "absolute", right: 10, top: 10 }}
115+
getCode={() => htmlCode}
116+
/>
117+
<HTMLPreview code={htmlCode} />
118+
</div>
119+
)}
155120
</>
156121
);
157122
}

app/config/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ export const getServerSideConfig = () => {
158158
alibabaUrl: process.env.ALIBABA_URL,
159159
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
160160

161+
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
162+
cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
163+
cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
164+
161165
gtmId: process.env.GTM_ID,
162166

163167
needCode: ACCESS_CODES.size > 0,

0 commit comments

Comments
 (0)