Initial aishare service

This commit is contained in:
Codex
2026-07-06 11:25:57 +00:00
commit e051b568ab
23 changed files with 5615 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>AI Share Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
import "./styles.css";
type Capture = {
id: string;
sourceUrl: string;
slug: string | null;
title: string | null;
status: "queued" | "capturing" | "rendering" | "ready" | "failed";
errorPublic: string | null;
createdAt: string;
updatedAt: string;
capturedAt: string | null;
publicUrl: string | null;
};
const tokenKey = "aishare.adminToken";
const app = document.querySelector<HTMLDivElement>("#app")!;
let token = localStorage.getItem(tokenKey) || "";
let page = 1;
const pageSize = 20;
let polling: number | undefined;
function api(path: string, options: RequestInit = {}) {
return fetch(`/aishare/api${path}`, {
...options,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
...(options.headers || {})
}
});
}
function fmt(value: string | null) {
if (!value) return "";
return new Date(value).toLocaleString();
}
function statusClass(status: Capture["status"]) {
return `pill ${status}`;
}
async function copy(text: string) {
await navigator.clipboard.writeText(text);
}
function renderShell() {
app.innerHTML = `
<main class="shell">
<header class="header">
<div>
<h1>AI Share Admin</h1>
<p>Capture public ChatGPT shares as read-only snapshots.</p>
</div>
<button class="icon-button" id="reset-token" title="Reset admin token" aria-label="Reset admin token">Reset</button>
</header>
<section class="panel token-panel" ${token ? "hidden" : ""}>
<label>Admin token</label>
<div class="row">
<input id="token-input" type="password" autocomplete="current-password" placeholder="AISHARE_ADMIN_TOKEN" />
<button id="save-token">Save</button>
</div>
</section>
<section class="panel" ${token ? "" : "hidden"}>
<form id="capture-form" class="capture-form">
<label>
ChatGPT share URL
<input name="sourceUrl" required inputmode="url" placeholder="https://chatgpt.com/share/..." />
</label>
<label>
Slug
<input name="slug" placeholder="Optional" />
</label>
<button type="submit">Capture</button>
</form>
<p id="form-status" class="status-line"></p>
</section>
<section class="panel table-panel" ${token ? "" : "hidden"}>
<div class="table-head">
<h2>Captured Shares</h2>
<div class="actions">
<button id="refresh">Refresh</button>
<button id="delete-all" class="danger">Delete all</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Link</th>
<th></th>
</tr>
</thead>
<tbody id="captures-body"></tbody>
</table>
</div>
<div class="pager">
<button id="prev">Previous</button>
<span id="page-label"></span>
<button id="next">Next</button>
</div>
</section>
</main>
`;
document.querySelector("#save-token")?.addEventListener("click", () => {
const input = document.querySelector<HTMLInputElement>("#token-input");
token = input?.value.trim() || "";
if (token) {
localStorage.setItem(tokenKey, token);
renderShell();
void loadCaptures();
}
});
document.querySelector("#reset-token")?.addEventListener("click", () => {
localStorage.removeItem(tokenKey);
token = "";
if (polling) window.clearInterval(polling);
renderShell();
});
document.querySelector("#capture-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
const status = document.querySelector("#form-status")!;
status.textContent = "Queued";
const response = await api("/captures", {
method: "POST",
body: JSON.stringify({
sourceUrl: data.get("sourceUrl"),
slug: data.get("slug")
})
});
const body = await response.json();
if (!response.ok) {
status.textContent = body.error || "Capture failed to queue";
return;
}
form.reset();
status.textContent = "Capture queued";
await loadCaptures();
startPolling();
});
document.querySelector("#refresh")?.addEventListener("click", () => loadCaptures());
document.querySelector("#delete-all")?.addEventListener("click", async () => {
if (!confirm("Delete all captures and related files?")) return;
await api("/captures", { method: "DELETE" });
page = 1;
await loadCaptures();
});
document.querySelector("#prev")?.addEventListener("click", async () => {
if (page > 1) {
page -= 1;
await loadCaptures();
}
});
document.querySelector("#next")?.addEventListener("click", async () => {
page += 1;
await loadCaptures();
});
}
async function loadCaptures() {
if (!token) return;
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`);
if (response.status === 401) {
localStorage.removeItem(tokenKey);
token = "";
renderShell();
return;
}
const body = await response.json();
const captures = body.captures as Capture[];
const tbody = document.querySelector<HTMLTableSectionElement>("#captures-body")!;
tbody.innerHTML = captures
.map((capture) => {
const title = capture.title || capture.slug || capture.sourceUrl;
const link = capture.publicUrl || "";
return `
<tr>
<td>
<div class="title-cell">${escapeHtml(title)}</div>
<div class="sub">${escapeHtml(capture.slug || capture.id)}</div>
${capture.errorPublic ? `<div class="error">${escapeHtml(capture.errorPublic)}</div>` : ""}
</td>
<td><span class="${statusClass(capture.status)}">${capture.status}</span></td>
<td>${escapeHtml(fmt(capture.createdAt))}</td>
<td>
${link ? `<button class="small" data-copy="${escapeHtml(link)}">Copy</button> <a class="small link" href="${escapeHtml(link)}" target="_blank" rel="noreferrer">Open</a>` : ""}
</td>
<td><button class="small danger" data-delete="${capture.id}">Delete</button></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => {
button.addEventListener("click", async () => {
await copy(button.dataset.copy || "");
button.textContent = "Copied";
setTimeout(() => (button.textContent = "Copy"), 1200);
});
});
tbody.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((button) => {
button.addEventListener("click", async () => {
if (!confirm("Delete this capture and related files?")) return;
await api(`/captures/${button.dataset.delete}`, { method: "DELETE" });
await loadCaptures();
});
});
const total = Number(body.total || 0);
document.querySelector("#page-label")!.textContent = `Page ${page} of ${Math.max(1, Math.ceil(total / pageSize))}`;
(document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1;
(document.querySelector("#next") as HTMLButtonElement).disabled = page * pageSize >= total;
if (captures.some((capture) => ["queued", "capturing", "rendering"].includes(capture.status))) startPolling();
}
function startPolling() {
if (polling) return;
polling = window.setInterval(async () => {
await loadCaptures();
const active = [...document.querySelectorAll(".pill")].some((pill) => ["queued", "capturing", "rendering"].includes(pill.textContent || ""));
if (!active && polling) {
window.clearInterval(polling);
polling = undefined;
}
}, 3000);
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
renderShell();
void loadCaptures();
+63
View File
@@ -0,0 +1,63 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1f2328;
--muted: #6b7280;
--border: #d9dee7;
--accent: #0f766e;
--danger: #b42318;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #18181b;
--panel: #242428;
--text: #f4f4f5;
--muted: #a1a1aa;
--border: #3f3f46;
--accent: #14b8a6;
--danger: #f87171;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; }
button, input { font: inherit; }
.shell { max-width: 1120px; margin: 0 auto; padding: 28px 20px 48px; }
.header { display: flex; justify-content: space-between; gap: 20px; align-items: start; margin-bottom: 22px; }
h1, h2, p { margin: 0; }
h1 { font-size: 30px; line-height: 1.15; letter-spacing: 0; }
h2 { font-size: 18px; }
.header p, .sub, .status-line { color: var(--muted); margin-top: 6px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 18px; margin-bottom: 18px; }
.row, .capture-form, .table-head, .actions, .pager { display: flex; gap: 10px; align-items: end; }
.capture-form { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(160px, 260px) auto; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
input { width: 100%; min-height: 40px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; background: var(--panel); color: var(--text); }
button, .small.link { min-height: 38px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 12px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; }
button[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; }
.danger { color: var(--danger); }
.icon-button { min-height: 34px; }
.table-head { justify-content: space-between; align-items: center; margin-bottom: 12px; }
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { font-size: 12px; color: var(--muted); font-weight: 650; }
tr:last-child td { border-bottom: 0; }
.title-cell { font-weight: 650; max-width: 420px; overflow-wrap: anywhere; }
.error { color: var(--danger); margin-top: 6px; max-width: 520px; }
.pill { display: inline-flex; align-items: center; min-height: 26px; border-radius: 999px; padding: 3px 9px; font-size: 12px; border: 1px solid var(--border); }
.pill.ready { color: var(--accent); }
.pill.failed { color: var(--danger); }
.pill.queued, .pill.capturing, .pill.rendering { color: #b7791f; }
.small { min-height: 32px; padding: 6px 9px; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; }
.pager { justify-content: flex-end; margin-top: 12px; color: var(--muted); }
@media (max-width: 760px) {
.shell { padding: 20px 14px 36px; }
.header, .table-head, .actions, .pager { align-items: stretch; flex-direction: column; }
.capture-form { grid-template-columns: 1fr; }
button { width: 100%; }
}