Initial aishare service
This commit is contained in:
@@ -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>
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
renderShell();
|
||||
void loadCaptures();
|
||||
@@ -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%; }
|
||||
}
|
||||
Reference in New Issue
Block a user