diff --git a/client/src/main.ts b/client/src/main.ts index 4eca0e2..7ef4b80 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -17,8 +17,9 @@ const tokenKey = "aishare.adminToken"; const app = document.querySelector("#app")!; let token = localStorage.getItem(tokenKey) || ""; let page = 1; -const pageSize = 20; +let pageSize = 20; let polling: number | undefined; +let toastTimer: number | undefined; function serviceBaseUrl() { const url = new URL(location.href); @@ -54,7 +55,39 @@ function statusClass(status: Capture["status"]) { } async function copy(text: string) { - await navigator.clipboard.writeText(text); + try { + await navigator.clipboard.writeText(text); + return; + } catch { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + textarea.remove(); + if (!ok) throw new Error("copy failed"); + } +} + +function showToast(message: string) { + let toast = document.querySelector("[data-toast]"); + if (!toast) { + toast = document.createElement("div"); + toast.className = "toast"; + toast.dataset.toast = "true"; + toast.setAttribute("role", "status"); + toast.setAttribute("aria-live", "polite"); + document.body.appendChild(toast); + } + toast.textContent = message; + toast.dataset.visible = "true"; + if (toastTimer) window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { + delete toast?.dataset.visible; + }, 1800); } function renderShell() { @@ -106,17 +139,22 @@ function renderShell() { Title Status Created - Link - + Actions -
- - - +
+ + + +
@@ -127,6 +165,7 @@ function renderShell() { token = input?.value.trim() || ""; if (token) { localStorage.setItem(tokenKey, token); + showToast("Admin token saved"); renderShell(); void loadCaptures(); } @@ -136,6 +175,7 @@ function renderShell() { localStorage.removeItem(tokenKey); token = ""; if (polling) window.clearInterval(polling); + showToast("Admin token reset"); renderShell(); }); @@ -155,20 +195,30 @@ function renderShell() { const body = await response.json(); if (!response.ok) { status.textContent = body.error || "Capture failed to queue"; + showToast(status.textContent); return; } form.reset(); status.textContent = "Capture queued"; + showToast("Capture queued"); await loadCaptures(); startPolling(); }); - document.querySelector("#refresh")?.addEventListener("click", () => loadCaptures()); + document.querySelector("#refresh")?.addEventListener("click", async () => { + await loadCaptures(); + showToast("Refreshed"); + }); document.querySelector("#delete-all")?.addEventListener("click", async () => { if (!confirm("Delete all captures and related files?")) return; - await api("/captures", { method: "DELETE" }); + const response = await api("/captures", { method: "DELETE" }); + if (!response.ok) { + showToast("Delete all failed"); + return; + } page = 1; await loadCaptures(); + showToast("All captures deleted"); }); document.querySelector("#prev")?.addEventListener("click", async () => { if (page > 1) { @@ -180,6 +230,16 @@ function renderShell() { page += 1; await loadCaptures(); }); + const pageSizeSelect = document.querySelector("#page-size"); + if (pageSizeSelect) { + pageSizeSelect.value = String(pageSize); + pageSizeSelect.addEventListener("change", async () => { + pageSize = Number(pageSizeSelect.value); + page = 1; + await loadCaptures(); + showToast(`${pageSize} rows per page`); + }); + } } async function loadCaptures() { @@ -207,31 +267,63 @@ async function loadCaptures() { ${capture.status} ${escapeHtml(fmt(capture.createdAt))} - - ${link ? ` Open` : ""} + +
+ ${link ? `Open` : ""} + ${capture.status === "failed" ? `` : ""} + +
- `; }) .join(""); tbody.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", async () => { - await copy(button.dataset.copy || ""); - button.textContent = "Copied"; - setTimeout(() => (button.textContent = "Copy"), 1200); + try { + await copy(button.dataset.copy || ""); + showToast("Copied"); + } catch { + showToast("Copy failed"); + } + }); + }); + tbody.querySelectorAll("[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("[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" }); + 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); - document.querySelector("#page-label")!.textContent = `Page ${page} of ${Math.max(1, Math.ceil(total / pageSize))}`; + const 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("#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; diff --git a/client/src/styles.css b/client/src/styles.css index 9804f93..c20577f 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -24,7 +24,7 @@ * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; } -button, input { font: inherit; } +button, input, select { 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; } @@ -35,7 +35,8 @@ h2 { font-size: 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); } +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[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; } .danger { color: var(--danger); } @@ -53,11 +54,23 @@ tr:last-child td { border-bottom: 0; } .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); } +.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); } +.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(--text); color: var(--bg); box-shadow: 0 10px 28px rgb(0 0 0 / 18%); 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, .pager { align-items: stretch; flex-direction: column; } + .header, .table-head, .actions { align-items: stretch; flex-direction: column; } .capture-form { grid-template-columns: 1fr; } button { width: 100%; } + .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; } }