Compact export toolbar and inline HTML assets

This commit is contained in:
Codex
2026-07-08 06:10:27 +00:00
parent 0c57525807
commit 746fe2a65d
3 changed files with 104 additions and 20 deletions
+29
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import path from "node:path";
import fs from "fs-extra";
import archiver from "archiver";
import * as cheerio from "cheerio";
import { config } from "./config.js";
import type { CaptureRow } from "./types.js";
@@ -61,3 +62,31 @@ export async function zipArtifact(slug: string, destination: string): Promise<vo
archive.finalize().catch(reject);
});
}
function contentTypeForAsset(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if (ext === ".svg") return "image/svg+xml";
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".webp") return "image/webp";
if (ext === ".gif") return "image/gif";
return "application/octet-stream";
}
export async function selfContainedHtml(slug: string): Promise<string> {
const html = await fs.readFile(artifactIndexPath(slug), "utf8");
const $ = cheerio.load(html);
for (const image of $("img").toArray()) {
const src = $(image).attr("src");
if (!src || src.startsWith("data:") || /^https?:\/\//i.test(src)) continue;
const normalized = path.normalize(src).replace(/^(\.\.(\/|\\|$))+/, "");
if (!normalized.startsWith("assets/")) continue;
const assetPath = path.join(artifactDir(slug), normalized);
const bytes = await fs.readFile(assetPath);
const data = `data:${contentTypeForAsset(assetPath)};base64,${bytes.toString("base64")}`;
$(image).attr("src", data);
}
return $.html();
}
+52 -20
View File
@@ -14,6 +14,13 @@ function escapeHtml(value: string): string {
.replace(/"/g, "&quot;");
}
const icons = {
download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v10m0 0 4-4m-4 4-4-4"/><path d="M5 18.5h14"/></svg>`,
image: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 4-4 3 3 2-2 3 3"/><circle cx="8.5" cy="8.5" r="1.2"/></svg>`,
share: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 12.5 15.5 8.5M8.5 11.5 15.5 15.5"/><circle cx="6.5" cy="12" r="2.5"/><circle cx="17.5" cy="7.5" r="2.5"/><circle cx="17.5" cy="16.5" r="2.5"/></svg>`,
chevron: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m8 10 4 4 4-4"/></svg>`
};
function css(): string {
return `
:root {
@@ -95,9 +102,19 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa
.content a[data-citation] { color: var(--muted); font-size: 0.92em; white-space: nowrap; text-decoration-thickness: 1px; text-underline-offset: 2px; }
.content a[data-citation] img { width: 14px; height: 14px; max-width: 14px; max-height: 14px; border-radius: 3px; object-fit: cover; vertical-align: -2px; margin: 0 3px 0 4px; }
.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; }
.downloads-inner { max-width: 880px; margin: 0 auto; padding: 14px 20px; display: flex; flex-wrap: nowrap; gap: 8px; align-items: center; overflow-x: auto; }
.icon-button { width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; display: inline-grid; place-items: center; flex: 0 0 auto; cursor: pointer; text-decoration: none; }
.icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.icon-button:hover { background: color-mix(in srgb, var(--panel) 88%, var(--text) 12%); }
.shot-control { display: inline-flex; align-items: stretch; flex: 0 0 auto; position: relative; }
.shot-control .shot-main { border-radius: 8px 0 0 8px; border-right: 0; }
.shot-control details { position: relative; }
.shot-control summary { list-style: none; border-radius: 0 8px 8px 0; width: 28px; }
.shot-control summary::-webkit-details-marker { display: none; }
.shot-control summary svg { width: 15px; height: 15px; }
.shot-menu { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 4; min-width: 150px; padding: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 30px rgba(0,0,0,.12); }
.shot-menu button { width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--text); padding: 8px 10px; font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
.shot-menu button:hover, .shot-menu button[data-current="true"] { background: var(--soft); }
.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; }
@@ -113,14 +130,21 @@ h1 { margin: 0; font-size: clamp(28px, 5vw, 44px); line-height: 1.12; letter-spa
.message.user .content { border-radius: 18px; padding: 9px 14px; }
.avatar, .assistant-icon { width: 26px; height: 26px; }
.content ul, .content ol { padding-left: 1.25em; }
.downloads-inner { padding: 18px 16px; align-items: stretch; }
.button, select { width: 100%; }
.downloads-inner { padding: 12px 16px; }
}`;
}
function pageScript(slug: string): string {
return `
(function () {
function currentVariant() {
var device = matchMedia("(max-width: 640px)").matches ? "mobile" : "desktop";
var theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return device + "-" + theme;
}
function downloadShot(variant) {
location.href = "/aishare/${slug}/screenshots/" + variant + ".png";
}
var params = new URLSearchParams(location.search);
var theme = params.get("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
@@ -140,13 +164,15 @@ function pageScript(slug: string): string {
setTimeout(function () { button.textContent = "Copy"; }, 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";
var defaultShot = document.querySelector("[data-shot-current]");
if (defaultShot) defaultShot.addEventListener("click", function () {
downloadShot(currentVariant());
});
document.querySelectorAll("[data-shot-variant]").forEach(function (button) {
if (button.dataset.shotVariant === currentVariant()) button.dataset.current = "true";
button.addEventListener("click", function () {
downloadShot(button.dataset.shotVariant);
});
});
})();`;
}
@@ -189,14 +215,20 @@ export function renderConversationPage(slug: string, conversation: NormalizedCon
</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>
<a class="icon-button" href="/aishare/${slug}/download" aria-label="Download export" title="Download export">${icons.download}</a>
<div class="shot-control">
<button class="icon-button shot-main" type="button" data-shot-current aria-label="Download screenshot" title="Download screenshot">${icons.image}</button>
<details>
<summary class="icon-button" aria-label="Choose screenshot variant" title="Choose screenshot variant">${icons.chevron}</summary>
<div class="shot-menu">
<button type="button" data-shot-variant="desktop-light">Desktop light</button>
<button type="button" data-shot-variant="desktop-dark">Desktop dark</button>
<button type="button" data-shot-variant="mobile-light">Mobile light</button>
<button type="button" data-shot-variant="mobile-dark">Mobile dark</button>
</div>
</details>
</div>
<button class="icon-button" type="button" data-copy aria-label="Copy link" title="Copy link">${icons.share}</button>
</div>
</section>
<footer class="source">
+23
View File
@@ -14,6 +14,7 @@ import {
deleteAllArtifactRoots,
deleteArtifactsFor,
screenshotPath,
selfContainedHtml,
zipArtifact
} from "./artifacts.js";
@@ -163,6 +164,28 @@ app.get(`${config.basePath}/:slug/download.html`, (req, res) => {
res.download(artifactIndexPath(req.params.slug), `${req.params.slug}.html`);
});
app.get(`${config.basePath}/:slug/download`, async (req, res, next) => {
try {
const row = store.getCaptureBySlug(req.params.slug);
if (!row) {
res.status(404).send("Not found");
return;
}
try {
const html = await selfContainedHtml(req.params.slug);
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.attachment(`${req.params.slug}.html`);
res.send(html);
} catch {
const zipPath = path.join(config.cacheDir, `${req.params.slug}.zip`);
await zipArtifact(req.params.slug, zipPath);
res.download(zipPath, `${req.params.slug}.zip`);
}
} catch (error) {
next(error);
}
});
app.get(`${config.basePath}/:slug/download.zip`, async (req, res, next) => {
try {
const row = store.getCaptureBySlug(req.params.slug);