Add anonymous captures and retention controls
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<title>AI Share</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+170
-205
@@ -10,35 +10,41 @@ type Capture = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
capturedAt: string | null;
|
||||
retentionDays: number;
|
||||
expiresAt: string;
|
||||
publicUrl: string | null;
|
||||
};
|
||||
|
||||
const tokenKey = "aishare.adminToken";
|
||||
const themeKey = "aishare.adminTheme";
|
||||
const themeKey = "aishare.theme";
|
||||
const legacyThemeKey = "aishare.adminTheme";
|
||||
const app = document.querySelector<HTMLDivElement>("#app")!;
|
||||
let token = localStorage.getItem(tokenKey) || "";
|
||||
let adminToken = "";
|
||||
let isAdmin = false;
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
let totalPages = 1;
|
||||
let polling: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
let keyValidationTimer: number | undefined;
|
||||
let manualTheme = false;
|
||||
|
||||
const icons = {
|
||||
sun: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/></svg>`,
|
||||
moon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.6A7.6 7.6 0 0 1 9.4 4 8 8 0 1 0 20 14.6Z"/></svg>`
|
||||
moon: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.6A7.6 7.6 0 0 1 9.4 4 8 8 0 1 0 20 14.6Z"/></svg>`,
|
||||
refresh: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 6"/><path d="M20 5v6h-6"/></svg>`
|
||||
};
|
||||
|
||||
function preferredTheme() {
|
||||
function preferredTheme(): "light" | "dark" {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function savedTheme() {
|
||||
const theme = localStorage.getItem(themeKey);
|
||||
function savedTheme(): "light" | "dark" | null {
|
||||
const theme = localStorage.getItem(themeKey) || localStorage.getItem(legacyThemeKey);
|
||||
return theme === "light" || theme === "dark" ? theme : null;
|
||||
}
|
||||
|
||||
function applyTheme(theme: "light" | "dark", manual: boolean) {
|
||||
function applyTheme(theme: "light" | "dark", manual: boolean): void {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
manualTheme = manual;
|
||||
@@ -46,60 +52,60 @@ function applyTheme(theme: "light" | "dark", manual: boolean) {
|
||||
updateThemeToggle();
|
||||
}
|
||||
|
||||
function updateThemeToggle() {
|
||||
function updateThemeToggle(): void {
|
||||
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
||||
const toggle = document.querySelector<HTMLButtonElement>("[data-theme-toggle]");
|
||||
if (!toggle) return;
|
||||
toggle.setAttribute("aria-label", theme === "dark" ? "Switch to light theme" : "Switch to dark theme");
|
||||
toggle.setAttribute("title", theme === "dark" ? "Switch to light theme" : "Switch to dark theme");
|
||||
const label = theme === "dark" ? "Switch to light theme" : "Switch to dark theme";
|
||||
toggle.setAttribute("aria-label", label);
|
||||
toggle.setAttribute("title", label);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
function initTheme(): void {
|
||||
const theme = savedTheme();
|
||||
applyTheme(theme || preferredTheme(), Boolean(theme));
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
systemTheme.addEventListener("change", () => {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
if (!manualTheme) applyTheme(preferredTheme(), false);
|
||||
});
|
||||
}
|
||||
|
||||
function serviceBaseUrl() {
|
||||
function serviceBaseUrl(): URL {
|
||||
const url = new URL(location.href);
|
||||
url.pathname = url.pathname.replace(/\/admin\/?$/, "/");
|
||||
if (!url.pathname.endsWith("/")) url.pathname += "/";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
function api(path: string, options: RequestInit = {}) {
|
||||
function api(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return fetch(new URL(`api${path}`, serviceBaseUrl()), {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(isAdmin && adminToken ? { authorization: `Bearer ${adminToken}` } : {}),
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function captureUrl(capture: Capture) {
|
||||
function captureUrl(capture: Capture): string {
|
||||
if (capture.slug) return new URL(`${capture.slug}/`, serviceBaseUrl()).href;
|
||||
return capture.publicUrl || "";
|
||||
}
|
||||
|
||||
function fmt(value: string | null) {
|
||||
if (!value) return "";
|
||||
function fmt(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function statusClass(status: Capture["status"]) {
|
||||
return `pill ${status}`;
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
async function copy(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
@@ -114,7 +120,7 @@ async function copy(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message: string) {
|
||||
function showToast(message: string): void {
|
||||
let toast = document.querySelector<HTMLDivElement>("[data-toast]");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
@@ -127,130 +133,110 @@ function showToast(message: string) {
|
||||
toast.textContent = message;
|
||||
toast.dataset.visible = "true";
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => {
|
||||
delete toast?.dataset.visible;
|
||||
}, 1800);
|
||||
toastTimer = window.setTimeout(() => delete toast?.dataset.visible, 1800);
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
function renderShell(): void {
|
||||
app.innerHTML = `
|
||||
<nav class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="${escapeHtml(serviceBaseUrl().pathname)}" aria-label="AI Share home"><span class="brand-mark">A</span><span>AI Share</span></a>
|
||||
<div class="nav-actions">
|
||||
<label class="admin-key-field">
|
||||
<span class="sr-only">Admin key</span>
|
||||
<input id="admin-key" type="password" autocomplete="current-password" placeholder="Admin key" value="${escapeHtml(adminToken)}" aria-label="Admin key" />
|
||||
<span class="key-state" data-key-state aria-hidden="true"></span>
|
||||
</label>
|
||||
<button class="icon-button theme-toggle" type="button" data-theme-toggle aria-label="Switch theme"><span class="sun-icon">${icons.sun}</span><span class="moon-icon">${icons.moon}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="shell">
|
||||
<header class="header">
|
||||
<section class="intro">
|
||||
<div>
|
||||
<h1>AI Share Admin</h1>
|
||||
<p>Capture public ChatGPT shares as read-only snapshots.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="icon-button theme-toggle" type="button" data-theme-toggle aria-label="Switch theme" title="Switch theme"><span class="sun-icon">${icons.sun}</span><span class="moon-icon">${icons.moon}</span></button>
|
||||
<button class="icon-button" id="reset-token" title="Reset admin token" aria-label="Reset admin token">Reset</button>
|
||||
</div>
|
||||
</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>
|
||||
<p class="eyebrow">Public ChatGPT snapshots</p>
|
||||
<h1>${isAdmin ? "Capture library" : "Your captures"}</h1>
|
||||
<p>${isAdmin ? "Manage every submitted snapshot and its retention window." : "Save a public ChatGPT conversation as an immutable page and screenshot."}</p>
|
||||
</div>
|
||||
${isAdmin ? '<span class="admin-badge">Admin view</span>' : ""}
|
||||
</section>
|
||||
|
||||
<section class="panel" ${token ? "" : "hidden"}>
|
||||
<section class="panel capture-panel">
|
||||
<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>
|
||||
<label class="url-field">ChatGPT share URL<input name="sourceUrl" required inputmode="url" autocomplete="url" placeholder="https://chatgpt.com/share/…" /></label>
|
||||
${isAdmin ? '<label class="slug-field">Custom slug<input name="slug" placeholder="Optional" /></label>' : ""}
|
||||
<label class="retention-field">Keep for<select name="retentionDays" aria-label="Retention period"><option value="3" selected>3 days</option><option value="7">7 days</option><option value="10">10 days</option><option value="30">30 days</option></select></label>
|
||||
<button class="primary" type="submit">Capture</button>
|
||||
</form>
|
||||
<p id="form-status" class="status-line"></p>
|
||||
<p id="form-status" class="status-line" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel" ${token ? "" : "hidden"}>
|
||||
<section class="panel table-panel">
|
||||
<div class="table-head">
|
||||
<h2>Captured Shares</h2>
|
||||
<div><h2>${isAdmin ? "All captures" : "Recent captures"}</h2><p class="table-summary" id="table-summary">Loading…</p></div>
|
||||
<div class="actions">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="delete-all" class="danger">Delete all</button>
|
||||
<button class="icon-button" id="refresh" title="Refresh" aria-label="Refresh">${icons.refresh}</button>
|
||||
${isAdmin ? '<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 colspan="2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="captures-body"></tbody>
|
||||
<thead><tr><th>Capture</th><th>Status</th><th>Created</th><th>Expires</th><th><span class="sr-only">Actions</span></th></tr></thead>
|
||||
<tbody id="captures-body"><tr><td colspan="5" class="empty-state">Loading captures…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager" aria-label="Pagination">
|
||||
<button class="pager-button" id="prev" aria-label="Previous page"><</button>
|
||||
<span id="page-label" class="page-label"></span>
|
||||
<select id="page-size" aria-label="Rows per page">
|
||||
<option value="10">10 rows</option>
|
||||
<option value="20">20 rows</option>
|
||||
<option value="50">50 rows</option>
|
||||
<option value="100">100 rows</option>
|
||||
</select>
|
||||
<button class="pager-button" id="next" aria-label="Next page">></button>
|
||||
<select id="page-size" aria-label="Rows per page"><option value="10">10 rows</option><option value="20">20 rows</option><option value="50">50 rows</option><option value="100">100 rows</option></select>
|
||||
<button class="pager-button" id="prev" aria-label="Previous page">←</button>
|
||||
<button class="pager-button" id="next" aria-label="Next page">→</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
</main>`;
|
||||
|
||||
document.querySelector("#save-token")?.addEventListener("click", () => {
|
||||
const input = document.querySelector<HTMLInputElement>("#token-input");
|
||||
token = input?.value.trim() || "";
|
||||
if (token) {
|
||||
localStorage.setItem(tokenKey, token);
|
||||
showToast("Admin token saved");
|
||||
renderShell();
|
||||
void loadCaptures();
|
||||
bindShellEvents();
|
||||
updateThemeToggle();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#reset-token")?.addEventListener("click", () => {
|
||||
localStorage.removeItem(tokenKey);
|
||||
token = "";
|
||||
if (polling) window.clearInterval(polling);
|
||||
showToast("Admin token reset");
|
||||
renderShell();
|
||||
function bindShellEvents(): void {
|
||||
const keyInput = document.querySelector<HTMLInputElement>("#admin-key");
|
||||
keyInput?.addEventListener("input", () => {
|
||||
if (keyValidationTimer) window.clearTimeout(keyValidationTimer);
|
||||
keyValidationTimer = window.setTimeout(() => void validateAdminKey(keyInput.value.trim()), 350);
|
||||
});
|
||||
keyInput?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (keyValidationTimer) window.clearTimeout(keyValidationTimer);
|
||||
void validateAdminKey(keyInput.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => {
|
||||
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
|
||||
});
|
||||
updateThemeToggle();
|
||||
|
||||
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 status = document.querySelector<HTMLElement>("#form-status")!;
|
||||
status.textContent = "Queueing capture…";
|
||||
const response = await api("/captures", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sourceUrl: data.get("sourceUrl"),
|
||||
slug: data.get("slug")
|
||||
})
|
||||
body: JSON.stringify({ sourceUrl: data.get("sourceUrl"), slug: isAdmin ? data.get("slug") : null, retentionDays: Number(data.get("retentionDays") || 3) })
|
||||
});
|
||||
const body = await response.json();
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
status.textContent = body.error || "Capture failed to queue";
|
||||
status.textContent = body.error || "Capture could not be queued.";
|
||||
showToast(status.textContent);
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
status.textContent = "Capture queued";
|
||||
status.textContent = "Capture queued.";
|
||||
showToast("Capture queued");
|
||||
page = 1;
|
||||
await loadCaptures();
|
||||
startPolling();
|
||||
});
|
||||
@@ -260,26 +246,18 @@ function renderShell() {
|
||||
showToast("Refreshed");
|
||||
});
|
||||
document.querySelector("#delete-all")?.addEventListener("click", async () => {
|
||||
if (!confirm("Delete all captures and related files?")) return;
|
||||
if (!confirm("Delete every capture and all related files?")) return;
|
||||
const response = await api("/captures", { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
showToast("Delete all failed");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) return showToast("Delete all failed");
|
||||
page = 1;
|
||||
await loadCaptures();
|
||||
showToast("All captures deleted");
|
||||
});
|
||||
document.querySelector("#prev")?.addEventListener("click", async () => {
|
||||
if (page > 1) {
|
||||
page -= 1;
|
||||
await loadCaptures();
|
||||
}
|
||||
if (page > 1) { page -= 1; await loadCaptures(); }
|
||||
});
|
||||
document.querySelector("#next")?.addEventListener("click", async () => {
|
||||
if (page >= totalPages) return;
|
||||
page += 1;
|
||||
await loadCaptures();
|
||||
if (page < totalPages) { page += 1; await loadCaptures(); }
|
||||
});
|
||||
const pageSizeSelect = document.querySelector<HTMLSelectElement>("#page-size");
|
||||
if (pageSizeSelect) {
|
||||
@@ -288,119 +266,106 @@ function renderShell() {
|
||||
pageSize = Number(pageSizeSelect.value);
|
||||
page = 1;
|
||||
await loadCaptures();
|
||||
showToast(`${pageSize} rows per page`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCaptures() {
|
||||
if (!token) return;
|
||||
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`);
|
||||
if (response.status === 401) {
|
||||
async function validateAdminKey(candidate: string, initial = false): Promise<void> {
|
||||
const response = await fetch(new URL("api/session", serviceBaseUrl()), {
|
||||
headers: candidate ? { authorization: `Bearer ${candidate}` } : {}
|
||||
});
|
||||
const body = await response.json().catch(() => ({ isAdmin: false }));
|
||||
const valid = Boolean(body.isAdmin);
|
||||
const changed = valid !== isAdmin || (valid && candidate !== adminToken);
|
||||
if (valid) {
|
||||
adminToken = candidate;
|
||||
isAdmin = true;
|
||||
localStorage.setItem(tokenKey, candidate);
|
||||
} else {
|
||||
adminToken = "";
|
||||
isAdmin = false;
|
||||
localStorage.removeItem(tokenKey);
|
||||
token = "";
|
||||
}
|
||||
if (changed || initial) {
|
||||
page = 1;
|
||||
if (polling) { window.clearInterval(polling); polling = undefined; }
|
||||
renderShell();
|
||||
await loadCaptures();
|
||||
if (!initial) showToast(valid ? "Admin view unlocked" : "Showing your captures");
|
||||
} else if (!valid) {
|
||||
document.querySelector("[data-key-state]")?.setAttribute("data-invalid", "true");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCaptures(): Promise<void> {
|
||||
const response = await api(`/captures?page=${page}&pageSize=${pageSize}`);
|
||||
if (!response.ok) {
|
||||
showToast("Could not load captures");
|
||||
return;
|
||||
}
|
||||
const body = await response.json();
|
||||
if (Boolean(body.isAdmin) !== isAdmin) {
|
||||
isAdmin = Boolean(body.isAdmin);
|
||||
if (!isAdmin) { adminToken = ""; localStorage.removeItem(tokenKey); }
|
||||
renderShell();
|
||||
return loadCaptures();
|
||||
}
|
||||
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 = captureUrl(capture);
|
||||
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 colspan="2">
|
||||
<div class="row-actions">
|
||||
${link ? `<button class="small" data-copy="${escapeHtml(link)}">Copy</button><a class="small link" href="${escapeHtml(link)}" target="_blank" rel="noreferrer">Open</a>` : ""}
|
||||
${capture.status === "failed" ? `<button class="small" data-retry="${capture.id}">Retry</button>` : ""}
|
||||
<button class="small danger" data-delete="${capture.id}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await copy(button.dataset.copy || "");
|
||||
showToast("Copied");
|
||||
} catch {
|
||||
showToast("Copy failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-retry]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const response = await api(`/captures/${button.dataset.retry}/retry`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
showToast(body.error || "Retry failed");
|
||||
return;
|
||||
}
|
||||
await loadCaptures();
|
||||
startPolling();
|
||||
showToast("Retry queued");
|
||||
});
|
||||
});
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
if (!confirm("Delete this capture and related files?")) return;
|
||||
const response = await api(`/captures/${button.dataset.delete}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
showToast("Delete failed");
|
||||
return;
|
||||
}
|
||||
await loadCaptures();
|
||||
showToast("Capture deleted");
|
||||
});
|
||||
});
|
||||
|
||||
const total = Number(body.total || 0);
|
||||
totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (page > totalPages) {
|
||||
page = totalPages;
|
||||
await loadCaptures();
|
||||
return;
|
||||
}
|
||||
document.querySelector("#page-label")!.textContent = `${page} / ${totalPages}`;
|
||||
const pageSizeSelect = document.querySelector<HTMLSelectElement>("#page-size");
|
||||
if (pageSizeSelect) pageSizeSelect.value = String(pageSize);
|
||||
(document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1;
|
||||
(document.querySelector("#next") as HTMLButtonElement).disabled = page * pageSize >= total;
|
||||
if (page > totalPages) { page = totalPages; return loadCaptures(); }
|
||||
|
||||
const tbody = document.querySelector<HTMLTableSectionElement>("#captures-body")!;
|
||||
tbody.innerHTML = captures.length ? captures.map(captureRow).join("") : `<tr><td colspan="5" class="empty-state"><strong>No captures yet</strong><span>Submit a public ChatGPT share above to get started.</span></td></tr>`;
|
||||
bindRowEvents(tbody);
|
||||
|
||||
document.querySelector("#table-summary")!.textContent = `${total} ${total === 1 ? "capture" : "captures"}${isAdmin ? " across all users" : " in this browser"}`;
|
||||
document.querySelector("#page-label")!.textContent = `Page ${page} of ${totalPages}`;
|
||||
const size = document.querySelector<HTMLSelectElement>("#page-size");
|
||||
if (size) size.value = String(pageSize);
|
||||
(document.querySelector("#prev") as HTMLButtonElement).disabled = page <= 1;
|
||||
(document.querySelector("#next") as HTMLButtonElement).disabled = page >= totalPages;
|
||||
if (captures.some((capture) => ["queued", "capturing", "rendering"].includes(capture.status))) startPolling();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
function captureRow(capture: Capture): string {
|
||||
const title = capture.title || capture.slug || capture.sourceUrl;
|
||||
const link = captureUrl(capture);
|
||||
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="pill ${capture.status}">${capture.status}</span></td>
|
||||
<td class="date-cell">${escapeHtml(fmt(capture.createdAt))}</td>
|
||||
<td class="date-cell"><strong>${capture.retentionDays} days</strong><span>${escapeHtml(fmt(capture.expiresAt))}</span></td>
|
||||
<td><div class="row-actions">${link ? `<button class="small" data-copy="${escapeHtml(link)}">Copy</button><a class="small link" href="${escapeHtml(link)}" target="_blank" rel="noreferrer">Open</a>` : ""}${capture.status === "failed" ? `<button class="small" data-retry="${capture.id}">Retry</button>` : ""}<button class="small danger" data-delete="${capture.id}">Delete</button></div></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bindRowEvents(tbody: HTMLTableSectionElement): void {
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-copy]").forEach((button) => button.addEventListener("click", async () => {
|
||||
try { await copy(button.dataset.copy || ""); showToast("Link copied"); } catch { showToast("Copy failed"); }
|
||||
}));
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-retry]").forEach((button) => button.addEventListener("click", async () => {
|
||||
const response = await api(`/captures/${button.dataset.retry}/retry`, { method: "POST" });
|
||||
if (!response.ok) return showToast("Retry failed");
|
||||
await loadCaptures(); startPolling(); showToast("Retry queued");
|
||||
}));
|
||||
tbody.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((button) => button.addEventListener("click", async () => {
|
||||
if (!confirm("Delete this capture and its files?")) return;
|
||||
const response = await api(`/captures/${button.dataset.delete}`, { method: "DELETE" });
|
||||
if (!response.ok) return showToast("Delete failed");
|
||||
await loadCaptures(); showToast("Capture deleted");
|
||||
}));
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
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;
|
||||
}
|
||||
if (!active && polling) { window.clearInterval(polling); polling = undefined; }
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
initTheme();
|
||||
renderShell();
|
||||
void loadCaptures();
|
||||
void validateAdminKey(localStorage.getItem(tokenKey) || "", true);
|
||||
|
||||
+102
-95
@@ -1,120 +1,127 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--bg: #f4f5f7;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--border: #d9dee7;
|
||||
--accent: #0f766e;
|
||||
--panel-soft: #f8f9fa;
|
||||
--text: #17191c;
|
||||
--muted: #69717d;
|
||||
--border: #dfe3e8;
|
||||
--accent: #0b7a65;
|
||||
--accent-hover: #096b59;
|
||||
--accent-soft: #e5f5f0;
|
||||
--danger: #b42318;
|
||||
--toast-bg: #ffffff;
|
||||
--toast-text: #1f2328;
|
||||
--shadow: rgb(15 23 42 / 14%);
|
||||
--danger-soft: #fff0ee;
|
||||
--warning: #9a6700;
|
||||
--shadow: rgb(24 29 36 / 8%);
|
||||
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;
|
||||
--toast-bg: #2f2f33;
|
||||
--toast-text: #f4f4f5;
|
||||
--shadow: rgb(0 0 0 / 34%);
|
||||
--bg: #171819; --panel: #222426; --panel-soft: #292c2f; --text: #f0f2f3; --muted: #a5abb3; --border: #3b3f44;
|
||||
--accent: #28b995; --accent-hover: #34caa4; --accent-soft: #173c34; --danger: #ff8177; --danger-soft: #442724; --warning: #e9b949; --shadow: rgb(0 0 0 / 28%);
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2328;
|
||||
--muted: #6b7280;
|
||||
--border: #d9dee7;
|
||||
--accent: #0f766e;
|
||||
--danger: #b42318;
|
||||
--toast-bg: #ffffff;
|
||||
--toast-text: #1f2328;
|
||||
--shadow: rgb(15 23 42 / 14%);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #18181b;
|
||||
--panel: #242428;
|
||||
--text: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--border: #3f3f46;
|
||||
--accent: #14b8a6;
|
||||
--danger: #f87171;
|
||||
--toast-bg: #2f2f33;
|
||||
--toast-text: #f4f4f5;
|
||||
--shadow: rgb(0 0 0 / 34%);
|
||||
}
|
||||
html[data-theme="light"] { color-scheme: light; --bg: #f4f5f7; --panel: #fff; --panel-soft: #f8f9fa; --text: #17191c; --muted: #69717d; --border: #dfe3e8; --accent: #0b7a65; --accent-hover: #096b59; --accent-soft: #e5f5f0; --danger: #b42318; --danger-soft: #fff0ee; --warning: #9a6700; --shadow: rgb(24 29 36 / 8%); }
|
||||
html[data-theme="dark"] { color-scheme: dark; --bg: #171819; --panel: #222426; --panel-soft: #292c2f; --text: #f0f2f3; --muted: #a5abb3; --border: #3b3f44; --accent: #28b995; --accent-hover: #34caa4; --accent-soft: #173c34; --danger: #ff8177; --danger-soft: #442724; --warning: #e9b949; --shadow: rgb(0 0 0 / 28%); }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; }
|
||||
html { min-height: 100%; background: var(--bg); }
|
||||
body { min-width: 320px; margin: 0; background: var(--bg); color: var(--text); font-size: 15px; }
|
||||
button, input, select { font: inherit; }
|
||||
body, .panel, input, select, button, .small.link, .table-wrap, th, td, .pill { transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; }
|
||||
.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; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
body, .navbar, .panel, input, select, button, .small.link, .table-wrap, th, td, .pill, .admin-badge { transition: background-color 220ms ease, border-color 220ms ease, color 220ms ease, box-shadow 220ms ease; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.navbar { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--panel) 92%, transparent); backdrop-filter: blur(14px); }
|
||||
.nav-inner { width: min(1180px, 100%); min-height: 64px; margin: 0 auto; padding: 10px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--text); font-size: 17px; font-weight: 720; text-decoration: none; letter-spacing: -.01em; }
|
||||
.brand-mark { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; background: var(--text); color: var(--panel); font-size: 15px; }
|
||||
.nav-actions { display: flex; align-items: center; gap: 9px; }
|
||||
.admin-key-field { position: relative; display: block; }
|
||||
.admin-key-field input { width: 184px; height: 38px; padding: 8px 32px 8px 11px; }
|
||||
.key-state { position: absolute; right: 11px; top: 50%; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); transform: translateY(-50%); opacity: 0; }
|
||||
.key-state[data-invalid="true"] { background: var(--danger); opacity: 1; }
|
||||
|
||||
.shell { width: min(1180px, 100%); margin: 0 auto; padding: 48px 24px 64px; }
|
||||
.intro { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 24px; }
|
||||
.eyebrow { margin: 0 0 7px; color: var(--accent); font-size: 12px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||
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, select { min-height: 40px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; background: var(--panel); color: var(--text); }
|
||||
input { width: 100%; }
|
||||
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:disabled { cursor: not-allowed; opacity: 0.42; }
|
||||
button[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.06; letter-spacing: -.035em; }
|
||||
h2 { font-size: 18px; letter-spacing: -.01em; }
|
||||
.intro div > p:last-child { max-width: 660px; margin-top: 10px; color: var(--muted); font-size: 16px; line-height: 1.55; }
|
||||
.admin-badge { align-self: center; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); border-radius: 999px; padding: 6px 11px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 700; }
|
||||
|
||||
.panel { margin-bottom: 18px; border: 1px solid var(--border); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px var(--shadow); }
|
||||
.capture-panel { padding: 18px; }
|
||||
.capture-form { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(150px, 220px) 130px auto; gap: 12px; align-items: end; }
|
||||
.capture-form:not(:has(.slug-field)) { grid-template-columns: minmax(300px, 1fr) 130px auto; }
|
||||
label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
input, select { width: 100%; min-height: 42px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 11px; outline: 0; background: var(--panel); color: var(--text); }
|
||||
input::placeholder { color: color-mix(in srgb, var(--muted) 72%, transparent); }
|
||||
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||
button, .small.link { min-height: 38px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; }
|
||||
button:hover, .small.link:hover { background: var(--panel-soft); }
|
||||
button:disabled { cursor: not-allowed; opacity: .42; }
|
||||
.primary { min-height: 42px; border-color: var(--accent); background: var(--accent); color: #fff; font-weight: 700; }
|
||||
.primary:hover { border-color: var(--accent-hover); background: var(--accent-hover); }
|
||||
.danger { color: var(--danger); }
|
||||
.icon-button { min-height: 34px; }
|
||||
.theme-toggle { width: 38px; min-height: 38px; padding: 8px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.theme-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.status-line { min-height: 18px; margin-top: 10px; color: var(--muted); font-size: 13px; }
|
||||
.icon-button { width: 38px; min-width: 38px; min-height: 38px; padding: 8px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.theme-toggle .moon-icon { display: none; }
|
||||
html[data-theme="dark"] .theme-toggle .sun-icon { display: none; }
|
||||
html[data-theme="dark"] .theme-toggle .moon-icon { display: block; }
|
||||
.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; }
|
||||
|
||||
.table-panel { padding: 18px; }
|
||||
.table-head { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 14px; }
|
||||
.table-summary { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||
.actions { display: flex; align-items: center; gap: 8px; }
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 9px; }
|
||||
table { width: 100%; min-width: 900px; border-collapse: collapse; }
|
||||
th, td { padding: 13px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||
th { background: var(--panel-soft); color: var(--muted); font-size: 11px; font-weight: 750; letter-spacing: .045em; text-transform: uppercase; }
|
||||
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; }
|
||||
.row-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; width: 100%; white-space: nowrap; }
|
||||
.row-actions .small { min-width: 58px; min-height: 32px; padding: 6px 9px; }
|
||||
.row-actions .danger { min-width: 66px; }
|
||||
.pager { justify-content: flex-end; align-items: center; margin-top: 12px; color: var(--muted); }
|
||||
.pager-button { width: 38px; min-height: 36px; padding: 6px 10px; }
|
||||
.page-label { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; font-variant-numeric: tabular-nums; color: var(--text); }
|
||||
.title-cell { max-width: 390px; font-weight: 680; overflow-wrap: anywhere; }
|
||||
.sub { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.error { max-width: 480px; margin-top: 6px; color: var(--danger); font-size: 13px; }
|
||||
.date-cell { color: var(--muted); font-size: 13px; white-space: nowrap; }
|
||||
.date-cell strong { display: block; color: var(--text); font-weight: 650; }
|
||||
.date-cell span { display: block; margin-top: 3px; font-size: 11px; }
|
||||
.pill { display: inline-flex; min-height: 25px; align-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 3px 9px; font-size: 11px; font-weight: 700; text-transform: capitalize; }
|
||||
.pill.ready { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: var(--accent-soft); color: var(--accent); }
|
||||
.pill.failed { background: var(--danger-soft); color: var(--danger); }
|
||||
.pill.queued, .pill.capturing, .pill.rendering { color: var(--warning); }
|
||||
.small { min-height: 30px; padding: 5px 8px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; }
|
||||
.row-actions { display: flex; justify-content: flex-end; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.empty-state { padding: 42px 20px; color: var(--muted); text-align: center; }
|
||||
.empty-state strong, .empty-state span { display: block; }
|
||||
.empty-state strong { margin-bottom: 5px; color: var(--text); font-size: 15px; }
|
||||
.pager { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 14px; }
|
||||
.page-label { margin-right: 4px; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.pager select { width: auto; min-height: 36px; padding: 6px 30px 6px 10px; }
|
||||
.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; transform: translate(-50%, 12px); padding: 9px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--toast-bg); color: var(--toast-text); box-shadow: 0 10px 28px var(--shadow); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; }
|
||||
.pager-button { width: 38px; min-height: 36px; padding: 6px; }
|
||||
.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; max-width: calc(100vw - 32px); transform: translate(-50%, 12px); border: 1px solid var(--border); border-radius: 9px; padding: 9px 13px; background: var(--panel); color: var(--text); box-shadow: 0 12px 32px var(--shadow); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; }
|
||||
.toast[data-visible="true"] { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell { padding: 20px 14px 36px; }
|
||||
.header, .table-head, .actions { align-items: stretch; flex-direction: column; }
|
||||
.header-actions { align-self: flex-end; }
|
||||
.capture-form { grid-template-columns: 1fr; }
|
||||
button { width: 100%; }
|
||||
.theme-toggle { width: 38px; }
|
||||
.row-actions { justify-content: flex-start; flex-wrap: wrap; }
|
||||
.row-actions .small, .pager button, .pager select { width: auto; }
|
||||
.pager { flex-direction: row; justify-content: flex-end; align-items: center; }
|
||||
.toast { width: max-content; max-width: calc(100vw - 28px); text-align: center; }
|
||||
@media (max-width: 820px) {
|
||||
.nav-inner { padding: 10px 16px; }
|
||||
.brand span:last-child { display: none; }
|
||||
.admin-key-field input { width: min(42vw, 184px); }
|
||||
.shell { padding: 34px 16px 48px; }
|
||||
.intro { align-items: flex-start; }
|
||||
.capture-form, .capture-form:not(:has(.slug-field)) { grid-template-columns: 1fr 130px; }
|
||||
.url-field, .slug-field { grid-column: 1 / -1; }
|
||||
.primary { width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.intro { display: block; }
|
||||
.admin-badge { display: inline-flex; margin-top: 14px; }
|
||||
.capture-form, .capture-form:not(:has(.slug-field)) { grid-template-columns: 1fr; }
|
||||
.url-field, .slug-field { grid-column: auto; }
|
||||
.table-head { align-items: flex-start; }
|
||||
.table-panel, .capture-panel { padding: 14px; }
|
||||
.pager { justify-content: space-between; flex-wrap: wrap; }
|
||||
.page-label { width: 100%; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface CaptureInsert {
|
||||
id: string;
|
||||
sourceUrl: string;
|
||||
requestedSlug: string | null;
|
||||
ownerToken: string | null;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export class Store {
|
||||
@@ -42,17 +44,43 @@ export class Store {
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_status ON captures(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_created_at ON captures(created_at);
|
||||
`);
|
||||
|
||||
const columns = new Set(
|
||||
(this.db.prepare("PRAGMA table_info(captures)").all() as Array<{ name: string }>).map((column) => column.name)
|
||||
);
|
||||
if (!columns.has("owner_token")) this.db.exec("ALTER TABLE captures ADD COLUMN owner_token TEXT");
|
||||
if (!columns.has("retention_days")) this.db.exec("ALTER TABLE captures ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 30");
|
||||
if (!columns.has("expires_at")) this.db.exec("ALTER TABLE captures ADD COLUMN expires_at TEXT");
|
||||
|
||||
const missingExpiry = this.db.prepare("SELECT id, created_at, retention_days FROM captures WHERE expires_at IS NULL").all() as Array<{
|
||||
id: string;
|
||||
created_at: string;
|
||||
retention_days: number;
|
||||
}>;
|
||||
const setExpiry = this.db.prepare("UPDATE captures SET expires_at = ? WHERE id = ?");
|
||||
const backfillExpiry = this.db.transaction(() => {
|
||||
for (const row of missingExpiry) {
|
||||
const expiresAt = new Date(new Date(row.created_at).getTime() + row.retention_days * 86_400_000).toISOString();
|
||||
setExpiry.run(expiresAt, row.id);
|
||||
}
|
||||
});
|
||||
backfillExpiry();
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_owner_created ON captures(owner_token, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_captures_expires_at ON captures(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
createCapture(input: CaptureInsert): CaptureRow {
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + input.retentionDays * 86_400_000).toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO captures
|
||||
(id, source_url, provider, requested_slug, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?)`
|
||||
(id, source_url, provider, requested_slug, status, created_at, updated_at, owner_token, retention_days, expires_at)
|
||||
VALUES (?, ?, 'chatgpt', ?, 'queued', ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(input.id, input.sourceUrl, input.requestedSlug, now, now);
|
||||
.run(input.id, input.sourceUrl, input.requestedSlug, now, now, input.ownerToken, input.retentionDays, expiresAt);
|
||||
return this.getCapture(input.id)!;
|
||||
}
|
||||
|
||||
@@ -65,15 +93,28 @@ export class Store {
|
||||
}
|
||||
|
||||
listCaptures(page: number, pageSize: number): { rows: CaptureRow[]; total: number } {
|
||||
return this.listCapturesWhere(page, pageSize);
|
||||
}
|
||||
|
||||
listCapturesForOwner(ownerToken: string, page: number, pageSize: number): { rows: CaptureRow[]; total: number } {
|
||||
return this.listCapturesWhere(page, pageSize, ownerToken);
|
||||
}
|
||||
|
||||
private listCapturesWhere(page: number, pageSize: number, ownerToken?: string): { rows: CaptureRow[]; total: number } {
|
||||
const limit = Math.min(Math.max(pageSize, 1), 100);
|
||||
const offset = Math.max(page - 1, 0) * limit;
|
||||
const rows = this.db
|
||||
.prepare("SELECT * FROM captures ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
||||
.all(limit, offset) as CaptureRow[];
|
||||
const total = (this.db.prepare("SELECT count(*) as count FROM captures").get() as { count: number }).count;
|
||||
const where = ownerToken === undefined ? "" : " WHERE owner_token = ?";
|
||||
const params = ownerToken === undefined ? [limit, offset] : [ownerToken, limit, offset];
|
||||
const rows = this.db.prepare(`SELECT * FROM captures${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params) as CaptureRow[];
|
||||
const countParams = ownerToken === undefined ? [] : [ownerToken];
|
||||
const total = (this.db.prepare(`SELECT count(*) as count FROM captures${where}`).get(...countParams) as { count: number }).count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
expiredCaptures(now = new Date().toISOString()): CaptureRow[] {
|
||||
return this.db.prepare("SELECT * FROM captures WHERE expires_at <= ?").all(now) as CaptureRow[];
|
||||
}
|
||||
|
||||
queuedCaptures(): CaptureRow[] {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM captures WHERE status IN ('queued', 'capturing', 'rendering') ORDER BY created_at ASC")
|
||||
|
||||
+93
-19
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import { config } from "./config.js";
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
} from "./artifacts.js";
|
||||
|
||||
const app = express();
|
||||
const retentionOptions = new Set([3, 7, 10, 30]);
|
||||
const ownerCookieName = "aishare_owner";
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.enable("strict routing");
|
||||
@@ -48,6 +51,45 @@ function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
res.status(401).json({ error: "Admin token required." });
|
||||
}
|
||||
|
||||
function isAdminRequest(req: Request): boolean {
|
||||
const header = req.header("authorization") || "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : req.header("x-admin-token");
|
||||
return Boolean(config.adminToken && token && token === config.adminToken);
|
||||
}
|
||||
|
||||
function cookieValue(req: Request, name: string): string | undefined {
|
||||
const cookie = req.header("cookie") || "";
|
||||
for (const part of cookie.split(";")) {
|
||||
const [key, ...value] = part.trim().split("=");
|
||||
if (key === name) return decodeURIComponent(value.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
let ownerToken = cookieValue(req, ownerCookieName);
|
||||
if (!ownerToken || !/^[A-Za-z0-9_-]{32,128}$/.test(ownerToken)) {
|
||||
ownerToken = crypto.randomBytes(32).toString("base64url");
|
||||
const cookiePath = config.basePath || "/";
|
||||
const secure = req.secure || req.header("x-forwarded-proto") === "https";
|
||||
res.cookie(ownerCookieName, ownerToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: cookiePath,
|
||||
maxAge: 365 * 24 * 60 * 60 * 1000
|
||||
});
|
||||
}
|
||||
(req as Request & { ownerToken?: string }).ownerToken = ownerToken;
|
||||
res.locals.ownerToken = ownerToken;
|
||||
next();
|
||||
});
|
||||
|
||||
function canManageCapture(req: Request, row: NonNullable<ReturnType<typeof store.getCapture>>): boolean {
|
||||
const ownerToken = (req as Request & { ownerToken?: string }).ownerToken;
|
||||
return isAdminRequest(req) || Boolean(row.owner_token && row.owner_token === ownerToken);
|
||||
}
|
||||
|
||||
function publicCapture(row: ReturnType<typeof store.getCapture>) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
@@ -61,6 +103,8 @@ function publicCapture(row: ReturnType<typeof store.getCapture>) {
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
capturedAt: row.captured_at,
|
||||
retentionDays: row.retention_days,
|
||||
expiresAt: row.expires_at,
|
||||
publicUrl: row.slug ? publicPath(row.slug) : null
|
||||
};
|
||||
}
|
||||
@@ -80,18 +124,35 @@ app.use(route("/assets"), express.static(path.resolve("dist-client/assets"), { i
|
||||
app.use(route("/admin-assets"), express.static(path.resolve("dist-client"), { immutable: true, maxAge: "1y" }));
|
||||
|
||||
app.get(route("/admin"), (_req, res) => {
|
||||
res.sendFile(path.resolve("dist-client/index.html"));
|
||||
res.redirect(302, route() || "/");
|
||||
});
|
||||
|
||||
function sendApp(_req: Request, res: Response): void {
|
||||
res.sendFile(path.resolve("dist-client/index.html"));
|
||||
}
|
||||
|
||||
app.get(route("/api/health"), (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post(route("/api/captures"), requireAdmin, (req, res) => {
|
||||
app.get(route("/api/session"), (req, res) => {
|
||||
res.json({ isAdmin: isAdminRequest(req) });
|
||||
});
|
||||
|
||||
app.post(route("/api/captures"), (req, res) => {
|
||||
try {
|
||||
const sourceUrl = validateChatGptShareUrl(String(req.body?.sourceUrl || ""));
|
||||
const requestedSlug = req.body?.slug ? slugify(String(req.body.slug)) : null;
|
||||
const row = store.createCapture({ id: newCaptureId(), sourceUrl, requestedSlug });
|
||||
const admin = isAdminRequest(req);
|
||||
const requestedSlug = admin && req.body?.slug ? slugify(String(req.body.slug)) : null;
|
||||
const retentionDays = Number(req.body?.retentionDays ?? 3);
|
||||
if (!retentionOptions.has(retentionDays)) throw new Error("Retention must be 3, 7, 10, or 30 days.");
|
||||
const row = store.createCapture({
|
||||
id: newCaptureId(),
|
||||
sourceUrl,
|
||||
requestedSlug,
|
||||
ownerToken: admin ? null : String(res.locals.ownerToken),
|
||||
retentionDays
|
||||
});
|
||||
captureQueue.enqueue(row.id);
|
||||
res.status(202).json({ capture: publicCapture(row) });
|
||||
} catch (error) {
|
||||
@@ -99,30 +160,32 @@ app.post(route("/api/captures"), requireAdmin, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get(route("/api/captures"), requireAdmin, (req, res) => {
|
||||
app.get(route("/api/captures"), (req, res) => {
|
||||
const page = Number.parseInt(String(req.query.page || "1"), 10);
|
||||
const pageSize = Number.parseInt(String(req.query.pageSize || "20"), 10);
|
||||
const result = store.listCaptures(page, pageSize);
|
||||
const admin = isAdminRequest(req);
|
||||
const result = admin ? store.listCaptures(page, pageSize) : store.listCapturesForOwner(String(res.locals.ownerToken), page, pageSize);
|
||||
res.json({
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
isAdmin: admin,
|
||||
captures: result.rows.map(publicCapture)
|
||||
});
|
||||
});
|
||||
|
||||
app.get(route("/api/captures/:id"), requireAdmin, (req, res) => {
|
||||
app.get(route("/api/captures/:id"), (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!row) {
|
||||
if (!row || !canManageCapture(req, row)) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
res.json({ capture: publicCapture(row), errorDetail: row.error_detail, metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null });
|
||||
});
|
||||
|
||||
app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => {
|
||||
app.post(route("/api/captures/:id/retry"), (req, res) => {
|
||||
const row = store.getCapture(req.params.id);
|
||||
if (!row) {
|
||||
if (!row || !canManageCapture(req, row)) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
@@ -135,12 +198,13 @@ app.post(route("/api/captures/:id/retry"), requireAdmin, (req, res) => {
|
||||
res.json({ capture: publicCapture(store.getCapture(row.id)) });
|
||||
});
|
||||
|
||||
app.delete(route("/api/captures/:id"), requireAdmin, async (req, res) => {
|
||||
const row = store.deleteCapture(req.params.id);
|
||||
if (!row) {
|
||||
app.delete(route("/api/captures/:id"), async (req, res) => {
|
||||
const existing = store.getCapture(req.params.id);
|
||||
if (!existing || !canManageCapture(req, existing)) {
|
||||
res.status(404).json({ error: "Capture not found." });
|
||||
return;
|
||||
}
|
||||
const row = store.deleteCapture(req.params.id)!;
|
||||
await deleteArtifactsFor(row);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -232,14 +296,13 @@ app.get(route("/:slug"), (req, res) => {
|
||||
res.redirect(308, `${req.originalUrl.replace(/[?#].*$/, "")}/`);
|
||||
});
|
||||
|
||||
app.get(route(), (_req, res) => {
|
||||
res.redirect(301, route("/admin"));
|
||||
});
|
||||
|
||||
if (config.basePath) {
|
||||
app.get(route("/"), (_req, res) => {
|
||||
res.redirect(301, route("/admin"));
|
||||
app.get(route(), (_req, res) => {
|
||||
res.redirect(308, route("/"));
|
||||
});
|
||||
app.get(route("/"), sendApp);
|
||||
} else {
|
||||
app.get("/", sendApp);
|
||||
}
|
||||
|
||||
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
@@ -250,7 +313,18 @@ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
await fs.ensureDir(config.htmlDir);
|
||||
await fs.ensureDir(config.screenshotDir);
|
||||
await fs.ensureDir(config.cacheDir);
|
||||
async function deleteExpiredCaptures(): Promise<void> {
|
||||
for (const row of store.expiredCaptures()) {
|
||||
const deleted = store.deleteCapture(row.id);
|
||||
if (deleted) await deleteArtifactsFor(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteExpiredCaptures();
|
||||
captureQueue.restorePending();
|
||||
setInterval(() => {
|
||||
void deleteExpiredCaptures().catch((error) => console.error("Failed to delete expired captures", error));
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
app.listen(config.port, "0.0.0.0", () => {
|
||||
console.log(`aishare listening on 0.0.0.0:${config.port}`);
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface CaptureRow {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
captured_at: string | null;
|
||||
owner_token: string | null;
|
||||
retention_days: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface NormalizedConversation {
|
||||
|
||||
Reference in New Issue
Block a user