214 lines
10 KiB
TypeScript
214 lines
10 KiB
TypeScript
import path from "node:path";
|
|
import fs from "fs-extra";
|
|
import type { CaptureRow, NormalizedConversation } from "./types.js";
|
|
import { artifactAssetsDir, artifactDir, artifactIndexPath } from "./artifacts.js";
|
|
import { config } from "./config.js";
|
|
|
|
const chatGptAvatar = `
|
|
<svg viewBox="0 0 40 40" role="img" aria-label="ChatGPT" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="20" cy="20" r="20" fill="#10a37f"/>
|
|
<path fill="#fff" d="M29.6 17.9a7 7 0 0 0-9.4-8.2 7 7 0 0 0-10.4 6.1 7 7 0 0 0 1.4 12.7 7 7 0 0 0 9.4 1.8 7 7 0 0 0 10.3-6.1 7 7 0 0 0-1.3-6.3Zm-9.4-6.2a5 5 0 0 1 6.9 5.4l-5.6-3.2a1 1 0 0 0-1 0l-7.6 4.4v-2.7l6.8-3.9h.5Zm-8.4 4.1a5 5 0 0 1 6.2-4.9l-5.5 3.2a1 1 0 0 0-.5.9v8.8l-2.4-1.4v-7.9c.6-.7 1.3-1.3 2.2-1.7Zm-1.2 10.5a5 5 0 0 1-2.8-8l.1 6.4c0 .4.2.7.5.9l7.6 4.4-2.4 1.4-6.8-3.9c-.2-.4-.2-.8-.2-1.2Zm9.2 3.9a5 5 0 0 1-6.9-5.4l5.6 3.2c.3.2.7.2 1 0l7.6-4.4v2.8l-6.8 3.9-.5-.1Zm8.4-4.1a5 5 0 0 1-6.2 4.9l5.5-3.2c.3-.2.5-.5.5-.9v-8.8l2.4 1.4v7.9c-.6.7-1.3 1.3-2.2 1.7Zm-8.2-.2-3-1.7v-3.5l3-1.7 3 1.7v3.5l-3 1.7Zm4-7-3.5-2a1 1 0 0 0-1 0l-3.5 2v-2.8l4-2.3 6.8 3.9c.2.4.2.8.2 1.2l-3-1.7Z"/>
|
|
</svg>`;
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function css(): string {
|
|
return `
|
|
:root {
|
|
color-scheme: light dark;
|
|
--bg: #ffffff;
|
|
--panel: #ffffff;
|
|
--text: #1f2328;
|
|
--muted: #6b7280;
|
|
--border: #e5e7eb;
|
|
--soft: #f7f7f8;
|
|
--code: #0f172a;
|
|
--code-bg: #f4f4f5;
|
|
--accent: #10a37f;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Thai", "Helvetica Neue", Arial, sans-serif;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #212121;
|
|
--panel: #212121;
|
|
--text: #ececec;
|
|
--muted: #a8a8a8;
|
|
--border: #3f3f46;
|
|
--soft: #2f2f2f;
|
|
--code: #e5e7eb;
|
|
--code-bg: #171717;
|
|
}
|
|
}
|
|
html[data-theme="light"] {
|
|
--bg: #ffffff; --panel: #ffffff; --text: #1f2328; --muted: #6b7280; --border: #e5e7eb; --soft: #f7f7f8; --code: #0f172a; --code-bg: #f4f4f5;
|
|
}
|
|
html[data-theme="dark"] {
|
|
--bg: #212121; --panel: #212121; --text: #ececec; --muted: #a8a8a8; --border: #3f3f46; --soft: #2f2f2f; --code: #e5e7eb; --code-bg: #171717;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--text); font-size: 16px; line-height: 1.65; }
|
|
a { color: inherit; }
|
|
.page { min-height: 100vh; }
|
|
.topbar { position: sticky; top: 0; z-index: 2; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(12px); }
|
|
.topbar-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
.snapshot-label { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
|
.title { max-width: 880px; margin: 0 auto; padding: 34px 20px 18px; }
|
|
h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spacing: 0; }
|
|
.conversation { max-width: 880px; margin: 0 auto; padding: 0 20px 36px; }
|
|
.message { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 16px; padding: 22px 0; border-top: 1px solid var(--border); }
|
|
.avatar { width: 32px; height: 32px; display: grid; place-items: center; margin-top: 2px; }
|
|
.avatar.user { border-radius: 50%; background: var(--soft); color: var(--text); font-weight: 650; font-size: 13px; }
|
|
.avatar svg { width: 32px; height: 32px; display: block; }
|
|
.role { color: var(--muted); font-size: 13px; margin-bottom: 4px; }
|
|
.content { min-width: 0; overflow-wrap: anywhere; }
|
|
.content p { margin: 0 0 1em; }
|
|
.content p:last-child { margin-bottom: 0; }
|
|
.content pre { margin: 1em 0; overflow: auto; padding: 14px 16px; border-radius: 8px; background: var(--code-bg); color: var(--code); font-size: 14px; line-height: 1.55; }
|
|
.content code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Noto Sans Mono", monospace; }
|
|
.content :not(pre) > code { background: var(--code-bg); border-radius: 5px; padding: 0.1em 0.32em; }
|
|
.content table { width: 100%; border-collapse: collapse; margin: 1em 0; display: block; overflow-x: auto; }
|
|
.content th, .content td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
.content blockquote { border-left: 3px solid var(--border); margin: 1em 0; padding-left: 14px; color: var(--muted); }
|
|
.content img { max-width: 100%; height: auto; border-radius: 8px; }
|
|
.downloads { border-top: 1px solid var(--border); background: var(--soft); }
|
|
.downloads-inner { max-width: 880px; margin: 0 auto; padding: 22px 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
.button, select { min-height: 38px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 8px 12px; font: inherit; font-size: 14px; text-decoration: none; }
|
|
.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
.source { max-width: 880px; margin: 0 auto; padding: 18px 20px 34px; color: var(--muted); font-size: 12px; }
|
|
.source a { color: var(--muted); }
|
|
.failure { max-width: 720px; margin: 0 auto; padding: 80px 20px; }
|
|
.failure h1 { font-size: 32px; }
|
|
@media (max-width: 640px) {
|
|
body { font-size: 15px; }
|
|
.topbar-inner { padding: 12px 16px; }
|
|
.title { padding: 26px 16px 12px; }
|
|
.conversation { padding: 0 16px 28px; }
|
|
.message { grid-template-columns: 32px minmax(0, 1fr); gap: 12px; padding: 18px 0; }
|
|
.avatar, .avatar svg { width: 28px; height: 28px; }
|
|
.downloads-inner { padding: 18px 16px; align-items: stretch; }
|
|
.button, select { width: 100%; }
|
|
}`;
|
|
}
|
|
|
|
function pageScript(slug: string): string {
|
|
return `
|
|
(function () {
|
|
var params = new URLSearchParams(location.search);
|
|
var theme = params.get("theme");
|
|
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
|
var copy = document.querySelector("[data-copy]");
|
|
if (copy) copy.addEventListener("click", async function () {
|
|
await navigator.clipboard.writeText(location.href.replace(/[?#].*$/, ""));
|
|
copy.textContent = "Copied";
|
|
setTimeout(function () { copy.textContent = "Copy link"; }, 1400);
|
|
});
|
|
var form = document.querySelector("[data-shot-form]");
|
|
if (form) form.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
var device = form.querySelector("[name=device]").value;
|
|
var selectedTheme = form.querySelector("[name=theme]").value;
|
|
if (selectedTheme === "system") selectedTheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
location.href = "/aishare/${slug}/screenshots/" + device + "-" + selectedTheme + ".png";
|
|
});
|
|
})();`;
|
|
}
|
|
|
|
function messageAvatar(role: string): string {
|
|
if (role === "assistant") return `<div class="avatar">${chatGptAvatar}</div>`;
|
|
if (role === "user") return `<div class="avatar user">You</div>`;
|
|
return `<div class="avatar user">i</div>`;
|
|
}
|
|
|
|
export function renderConversationPage(slug: string, conversation: NormalizedConversation): string {
|
|
const title = escapeHtml(conversation.title);
|
|
const messages = conversation.messages
|
|
.map((message) => `
|
|
<article class="message ${message.role}">
|
|
${messageAvatar(message.role)}
|
|
<div class="content-wrap">
|
|
<div class="role">${message.role === "assistant" ? "Assistant" : message.role === "user" ? "You" : "Message"}</div>
|
|
<div class="content">${message.html}</div>
|
|
</div>
|
|
</article>`)
|
|
.join("\n");
|
|
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<title>${title}</title>
|
|
<style>${css()}</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="topbar"><div class="topbar-inner"><strong>AI Share</strong><span class="snapshot-label">ChatGPT snapshot</span></div></header>
|
|
<main>
|
|
<section class="title"><h1>${title}</h1></section>
|
|
<section class="conversation">${messages}</section>
|
|
</main>
|
|
<section class="downloads">
|
|
<div class="downloads-inner">
|
|
<a class="button primary" href="/aishare/${slug}/download.zip">Download HTML ZIP</a>
|
|
<a class="button" href="/aishare/${slug}/download.html">Download HTML</a>
|
|
<form data-shot-form style="display: contents">
|
|
<select name="device" aria-label="Screenshot size"><option value="desktop">Desktop</option><option value="mobile">Mobile</option></select>
|
|
<select name="theme" aria-label="Screenshot theme"><option value="system">Current theme</option><option value="light">Light</option><option value="dark">Dark</option></select>
|
|
<button class="button" type="submit">Download screenshot</button>
|
|
</form>
|
|
<button class="button" type="button" data-copy>Copy link</button>
|
|
</div>
|
|
</section>
|
|
<footer class="source">
|
|
Captured from ChatGPT on ${escapeHtml(new Date(conversation.capturedAt).toLocaleString())}.
|
|
Source: <a href="${escapeHtml(conversation.sourceUrl)}" rel="nofollow noreferrer">${escapeHtml(conversation.sourceUrl)}</a>
|
|
</footer>
|
|
</div>
|
|
<script>${pageScript(slug)}</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function renderFailurePage(row: CaptureRow): string {
|
|
const title = escapeHtml(row.title || "Capture failed");
|
|
const reason = escapeHtml(row.error_public || "This ChatGPT share could not be captured.");
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
<title>${title}</title>
|
|
<style>${css()}</style>
|
|
</head>
|
|
<body>
|
|
<main class="failure">
|
|
<p class="snapshot-label">AI Share capture</p>
|
|
<h1>${title}</h1>
|
|
<p>${reason}</p>
|
|
</main>
|
|
<footer class="source">
|
|
Source: <a href="${escapeHtml(row.source_url)}" rel="nofollow noreferrer">${escapeHtml(row.source_url)}</a>
|
|
</footer>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export async function writeConversationArtifact(slug: string, conversation: NormalizedConversation): Promise<void> {
|
|
await fs.ensureDir(artifactAssetsDir(slug));
|
|
await fs.writeFile(artifactIndexPath(slug), renderConversationPage(slug, conversation), "utf8");
|
|
}
|
|
|
|
export async function writeFailureArtifact(row: CaptureRow): Promise<void> {
|
|
if (!row.slug) return;
|
|
await fs.ensureDir(artifactDir(row.slug));
|
|
await fs.writeFile(path.join(artifactDir(row.slug), "index.html"), renderFailurePage(row), "utf8");
|
|
}
|