Skip to content

Commit b27f394

Browse files
authored
Merge pull request #5092 from ConnectAI-E/feature-artifacts
[Artifacts] add preview html code
2 parents f5499ff + 3f9f556 commit b27f394

File tree

13 files changed

+512
-23
lines changed

13 files changed

+512
-23
lines changed

app/api/artifacts/route.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 = () =>
8+
`https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
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 body: {
16+
key: string;
17+
value: string;
18+
expiration_ttl?: number;
19+
} = {
20+
key: hashedCode,
21+
value: clonedBody,
22+
};
23+
try {
24+
const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
25+
if (ttl > 60) {
26+
body["expiration_ttl"] = ttl;
27+
}
28+
} catch (e) {
29+
console.error(e);
30+
}
31+
const res = await fetch(`${storeUrl()}/bulk`, {
32+
headers: {
33+
...storeHeaders(),
34+
"Content-Type": "application/json",
35+
},
36+
method: "PUT",
37+
body: JSON.stringify([body]),
38+
});
39+
const result = await res.json();
40+
console.log("save data", result);
41+
if (result?.success) {
42+
return NextResponse.json(
43+
{ code: 0, id: hashedCode, result },
44+
{ status: res.status },
45+
);
46+
}
47+
return NextResponse.json(
48+
{ error: true, msg: "Save data error" },
49+
{ status: 400 },
50+
);
51+
}
52+
if (req.method === "GET") {
53+
const id = req?.nextUrl?.searchParams?.get("id");
54+
const res = await fetch(`${storeUrl()}/values/${id}`, {
55+
headers: storeHeaders(),
56+
method: "GET",
57+
});
58+
return new Response(res.body, {
59+
status: res.status,
60+
statusText: res.statusText,
61+
headers: res.headers,
62+
});
63+
}
64+
return NextResponse.json(
65+
{ error: true, msg: "Invalid request" },
66+
{ status: 400 },
67+
);
68+
}
69+
70+
export const POST = handle;
71+
export const GET = handle;
72+
73+
export const runtime = "edge";

app/components/artifacts.module.scss

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
.artifacts {
2+
display: flex;
3+
width: 100%;
4+
height: 100%;
5+
flex-direction: column;
6+
&-header {
7+
display: flex;
8+
align-items: center;
9+
height: 36px;
10+
padding: 20px;
11+
background: var(--second);
12+
}
13+
&-title {
14+
flex: 1;
15+
text-align: center;
16+
font-weight: bold;
17+
font-size: 24px;
18+
}
19+
&-content {
20+
flex-grow: 1;
21+
padding: 0 20px 20px 20px;
22+
background-color: var(--second);
23+
}
24+
}
25+
26+
.artifacts-iframe {
27+
width: 100%;
28+
border: var(--border-in-light);
29+
border-radius: 6px;
30+
background-color: var(--gray);
31+
}

app/components/artifacts.tsx

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

0 commit comments

Comments
 (0)