Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>ScrubData · Trailhead</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bark: #2f2a22; | |
| --moss: #3f6f4f; | |
| --moss-deep: #2c5239; | |
| --fern: #5a9367; | |
| --amber: #d98c2b; | |
| --amber-soft: #f0b95c; | |
| --cream: #f7f1e3; | |
| --cream-2: #efe6d2; | |
| --paper: #fdf9ef; | |
| --clay: #c2693b; | |
| --ink-soft: #6b6354; | |
| --line: #d8cbb0; | |
| --rust: #b04a3a; | |
| --shadow: rgba(47,42,34,.16); | |
| --radius: 16px; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| font-family: "DM Sans", system-ui, sans-serif; | |
| color: var(--bark); | |
| background-color: var(--cream); | |
| background-image: | |
| radial-gradient(circle at 12% 18%, rgba(90,147,103,.10), transparent 38%), | |
| radial-gradient(circle at 88% 8%, rgba(217,140,43,.12), transparent 42%), | |
| radial-gradient(circle at 50% 120%, rgba(63,111,79,.10), transparent 55%); | |
| min-height: 100vh; | |
| } | |
| /* faint topo-map contour texture */ | |
| body::before { | |
| content: ""; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 0; | |
| opacity: .5; | |
| background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220' viewBox='0 0 220 220'><g fill='none' stroke='%233f6f4f' stroke-opacity='0.07' stroke-width='1.4'><path d='M10 120 Q60 60 110 110 T210 100'/><path d='M0 160 Q60 110 120 150 T220 140'/><path d='M-10 70 Q50 20 110 60 T220 50'/></g></svg>"); | |
| } | |
| .wrap { | |
| position: relative; | |
| z-index: 1; | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 38px 26px 80px; | |
| } | |
| /* ---------- HEADER ---------- */ | |
| header.hero { | |
| display: flex; | |
| align-items: center; | |
| gap: 18px; | |
| margin-bottom: 8px; | |
| } | |
| .badge-trail { | |
| flex: none; | |
| width: 64px; height: 64px; | |
| border-radius: 18px; | |
| background: linear-gradient(150deg, var(--moss), var(--moss-deep)); | |
| display: grid; place-items: center; | |
| box-shadow: 0 8px 20px var(--shadow), inset 0 0 0 3px rgba(255,255,255,.12); | |
| transform: rotate(-4deg); | |
| } | |
| .badge-trail svg { width: 40px; height: 40px; } | |
| .hero h1 { | |
| font-family: "Fraunces", serif; | |
| font-weight: 700; | |
| font-size: clamp(2rem, 4.5vw, 3.1rem); | |
| line-height: .98; | |
| margin: 0; | |
| color: var(--bark); | |
| letter-spacing: -.5px; | |
| } | |
| .hero h1 .dot { color: var(--amber); } | |
| .hero .kicker { | |
| font-family: "Caveat", cursive; | |
| font-size: 1.55rem; | |
| color: var(--moss-deep); | |
| transform: rotate(-2deg); | |
| margin-top: 2px; | |
| display: inline-block; | |
| } | |
| .tagline { | |
| font-size: 1.05rem; | |
| color: var(--ink-soft); | |
| max-width: 640px; | |
| margin: 14px 0 30px; | |
| line-height: 1.5; | |
| } | |
| .tagline b { color: var(--moss-deep); } | |
| /* ---------- CARD ---------- */ | |
| .card { | |
| background: var(--paper); | |
| border: 1.5px solid var(--line); | |
| border-radius: var(--radius); | |
| box-shadow: 0 10px 30px var(--shadow); | |
| padding: 26px; | |
| position: relative; | |
| } | |
| /* little "stitched" dashed inner border for the trail-map feel */ | |
| .card.dashed::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 9px; | |
| border: 2px dashed var(--line); | |
| border-radius: 11px; | |
| pointer-events: none; | |
| } | |
| /* ---------- DROPZONE ---------- */ | |
| .stage { display: grid; grid-template-columns: 1.3fr .9fr; gap: 22px; align-items: stretch; } | |
| @media (max-width: 820px){ .stage { grid-template-columns: 1fr; } } | |
| .dropzone { | |
| border: 2.5px dashed var(--fern); | |
| border-radius: 14px; | |
| background: | |
| repeating-linear-gradient(135deg, rgba(90,147,103,.05) 0 14px, transparent 14px 28px), | |
| var(--cream-2); | |
| padding: 34px 24px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: border-color .18s, background .18s, transform .18s; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| min-height: 220px; | |
| } | |
| .dropzone:hover { border-color: var(--moss); transform: translateY(-2px); } | |
| .dropzone.drag { | |
| border-color: var(--amber); | |
| background: var(--cream); | |
| box-shadow: inset 0 0 0 3px rgba(217,140,43,.25); | |
| } | |
| .dropzone .foot { | |
| font-size: 3.2rem; line-height: 1; margin-bottom: 8px; | |
| filter: drop-shadow(0 4px 6px rgba(47,42,34,.18)); | |
| } | |
| .dropzone h3 { margin: 4px 0 4px; font-family: "Fraunces", serif; font-size: 1.3rem; font-weight: 600; } | |
| .dropzone p { margin: 0; color: var(--ink-soft); font-size: .95rem; } | |
| .filechip { | |
| margin-top: 14px; | |
| display: none; | |
| align-items: center; gap: 8px; | |
| background: var(--moss); | |
| color: var(--paper); | |
| font-weight: 600; font-size: .9rem; | |
| padding: 7px 14px; border-radius: 999px; | |
| box-shadow: 0 4px 10px var(--shadow); | |
| } | |
| .filechip.show { display: inline-flex; } | |
| /* ---------- ACTION PANEL ---------- */ | |
| .panel { display: flex; flex-direction: column; justify-content: center; gap: 16px; } | |
| .btn { | |
| font-family: "DM Sans", sans-serif; | |
| font-weight: 700; | |
| font-size: 1.05rem; | |
| border: none; | |
| border-radius: 13px; | |
| padding: 16px 22px; | |
| cursor: pointer; | |
| display: inline-flex; align-items: center; justify-content: center; gap: 10px; | |
| transition: transform .12s, box-shadow .12s, filter .12s; | |
| } | |
| .btn:active { transform: translateY(1px); } | |
| .btn-primary { | |
| background: linear-gradient(150deg, var(--amber-soft), var(--amber)); | |
| color: #3a2a10; | |
| box-shadow: 0 8px 18px rgba(217,140,43,.4), inset 0 1px 0 rgba(255,255,255,.5); | |
| } | |
| .btn-primary:hover:not(:disabled) { filter: brightness(1.04); box-shadow: 0 10px 24px rgba(217,140,43,.5); } | |
| .btn-primary:disabled { filter: grayscale(.5) brightness(.95); cursor: not-allowed; opacity: .75; } | |
| .btn-ghost { | |
| background: transparent; | |
| color: var(--moss-deep); | |
| border: 1.5px solid var(--line); | |
| font-weight: 600; font-size: .95rem; | |
| padding: 12px 16px; | |
| } | |
| .btn-ghost:hover { background: var(--cream-2); border-color: var(--fern); } | |
| .hint { | |
| font-family: "Caveat", cursive; | |
| font-size: 1.15rem; | |
| color: var(--clay); | |
| text-align: center; | |
| transform: rotate(-1deg); | |
| } | |
| .hint code { | |
| font-family: "DM Sans", sans-serif; | |
| font-size: .8rem; | |
| background: var(--cream-2); | |
| padding: 1px 6px; border-radius: 5px; | |
| color: var(--bark); | |
| } | |
| /* ---------- STATUS / ERROR ---------- */ | |
| .status { | |
| margin-top: 22px; | |
| display: none; | |
| align-items: center; gap: 14px; | |
| padding: 16px 20px; | |
| border-radius: 12px; | |
| background: var(--cream-2); | |
| border: 1.5px solid var(--line); | |
| font-weight: 600; | |
| } | |
| .status.show { display: flex; } | |
| .status.err { background: #fbeae6; border-color: var(--rust); color: var(--rust); } | |
| /* scrubbing animation */ | |
| .scrubber { position: relative; width: 52px; height: 30px; flex: none; } | |
| .scrubber .suds { | |
| position: absolute; inset: 0; | |
| background: | |
| radial-gradient(circle at 30% 60%, rgba(90,147,103,.5) 4px, transparent 5px), | |
| radial-gradient(circle at 65% 35%, rgba(217,140,43,.5) 5px, transparent 6px), | |
| radial-gradient(circle at 50% 70%, rgba(90,147,103,.4) 3px, transparent 4px); | |
| } | |
| .scrubber .brush { | |
| position: absolute; top: 4px; left: 0; | |
| font-size: 1.5rem; | |
| animation: scrub 0.7s ease-in-out infinite; | |
| } | |
| @keyframes scrub { | |
| 0%,100% { transform: translateX(0) rotate(-8deg); } | |
| 50% { transform: translateX(22px) rotate(8deg); } | |
| } | |
| /* ---------- RESULTS ---------- */ | |
| .results { margin-top: 30px; display: none; } | |
| .results.show { display: block; animation: rise .4s ease both; } | |
| @keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } | |
| .section-tab { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| font-family: "Fraunces", serif; font-weight: 600; | |
| font-size: 1.25rem; | |
| background: var(--moss); | |
| color: var(--paper); | |
| padding: 7px 18px 7px 14px; | |
| border-radius: 10px 10px 0 0; | |
| transform: translateY(1px); | |
| box-shadow: 0 -2px 6px var(--shadow); | |
| } | |
| .summary-card { | |
| background: linear-gradient(150deg, var(--moss-deep), var(--moss)); | |
| color: var(--paper); | |
| border-radius: 14px; | |
| padding: 22px 26px; | |
| margin-bottom: 26px; | |
| box-shadow: 0 10px 26px var(--shadow); | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .summary-card::after { | |
| content: "🏕️"; | |
| position: absolute; right: 18px; bottom: -6px; | |
| font-size: 4.5rem; opacity: .16; | |
| } | |
| .summary-card .lbl { | |
| font-family: "Caveat", cursive; font-size: 1.3rem; | |
| color: var(--amber-soft); margin-bottom: 2px; | |
| } | |
| .summary-card p { margin: 0; font-size: 1.15rem; line-height: 1.45; max-width: 80%; } | |
| .stats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 28px; } | |
| .stat { | |
| flex: 1 1 130px; | |
| background: var(--paper); | |
| border: 1.5px solid var(--line); | |
| border-radius: 12px; | |
| padding: 14px 18px; | |
| box-shadow: 0 4px 12px var(--shadow); | |
| } | |
| .stat .num { font-family: "Fraunces", serif; font-size: 1.8rem; font-weight: 700; color: var(--moss-deep); line-height: 1; } | |
| .stat .cap { font-size: .82rem; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .04em; margin-top: 5px; } | |
| .tables { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; } | |
| @media (max-width: 900px){ .tables { grid-template-columns: 1fr; } } | |
| .tablecard { display: flex; flex-direction: column; } | |
| .tablecard .head { | |
| display: flex; align-items: baseline; gap: 10px; | |
| margin-bottom: 6px; | |
| } | |
| .tablecard .head h4 { font-family: "Fraunces", serif; margin: 0; font-size: 1.15rem; } | |
| .tag { font-size: .72rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: .05em; } | |
| .tag.before { background: #f5e0d6; color: var(--rust); } | |
| .tag.after { background: #d9ecdc; color: var(--moss-deep); } | |
| .scroll { | |
| overflow: auto; | |
| max-height: 360px; | |
| border: 1.5px solid var(--line); | |
| border-radius: 12px; | |
| background: var(--paper); | |
| } | |
| table.grid { border-collapse: collapse; width: 100%; font-size: .82rem; white-space: nowrap; } | |
| table.grid th { | |
| position: sticky; top: 0; | |
| background: var(--cream-2); | |
| color: var(--bark); | |
| text-align: left; | |
| font-weight: 700; | |
| padding: 9px 12px; | |
| border-bottom: 2px solid var(--line); | |
| z-index: 1; | |
| } | |
| table.grid td { | |
| padding: 8px 12px; | |
| border-bottom: 1px solid var(--line); | |
| color: var(--bark); | |
| } | |
| table.grid tr:nth-child(even) td { background: rgba(239,230,210,.4); } | |
| table.grid td.empty { color: #b9ac90; font-style: italic; } | |
| /* ---------- REPORT ---------- */ | |
| .report-card { background: var(--paper); border: 1.5px solid var(--line); border-radius: 0 14px 14px 14px; padding: 24px 28px; box-shadow: 0 8px 22px var(--shadow); margin-bottom: 26px; } | |
| .report-card h1, .report-card h2, .report-card h3 { font-family: "Fraunces", serif; color: var(--moss-deep); margin: 18px 0 8px; } | |
| .report-card h1 { font-size: 1.5rem; margin-top: 0; } | |
| .report-card h2 { font-size: 1.25rem; } | |
| .report-card h3 { font-size: 1.08rem; } | |
| .report-card p, .report-card li { line-height: 1.6; color: var(--bark); } | |
| .report-card ul { padding-left: 22px; } | |
| .report-card li { margin: 4px 0; } | |
| .report-card code { background: var(--cream-2); padding: 1px 6px; border-radius: 5px; font-size: .88em; } | |
| .report-card strong { color: var(--moss-deep); } | |
| .report-card hr { border: none; border-top: 1.5px dashed var(--line); margin: 18px 0; } | |
| .report-card table { border-collapse: collapse; margin: 10px 0; } | |
| .report-card th, .report-card td { border: 1px solid var(--line); padding: 6px 12px; text-align: left; font-size: .9rem; } | |
| .report-card th { background: var(--cream-2); } | |
| .download-row { display: flex; justify-content: center; padding: 8px 0 0; } | |
| .btn-download { | |
| background: linear-gradient(150deg, var(--fern), var(--moss-deep)); | |
| color: var(--paper); | |
| box-shadow: 0 8px 18px rgba(44,82,57,.4); | |
| font-size: 1.1rem; padding: 16px 32px; | |
| } | |
| .btn-download:hover { filter: brightness(1.05); } | |
| footer { text-align: center; margin-top: 50px; color: var(--ink-soft); font-size: .85rem; } | |
| footer .star { color: var(--amber); } | |
| .spin { display:inline-block; animation: spin 1s linear infinite; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header class="hero"> | |
| <div class="badge-trail" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="#f7f1e3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M3 20h18"/><path d="M7 20l4-13 3 6 2-4 3 11"/><circle cx="11" cy="7" r="1.4" fill="#f0b95c" stroke="none"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <h1>ScrubData<span class="dot">.</span></h1> | |
| <span class="kicker">your data, back on the trail</span> | |
| </div> | |
| </header> | |
| <p class="tagline"> | |
| Drop in a messy spreadsheet and we'll <b>tidy the whole trail</b> — trimming stray | |
| whitespace, fixing $ amounts stored as text, unifying spellings, and de-duping rows. | |
| Every change is <b>shown and explained</b>, so you can vouch for the result. | |
| </p> | |
| <!-- INPUT CARD --> | |
| <div class="card dashed"> | |
| <div class="stage"> | |
| <label class="dropzone" id="dropzone"> | |
| <input type="file" id="fileInput" accept=".csv,.xlsx,.xls" hidden /> | |
| <div class="foot">🥾</div> | |
| <h3>Drop your CSV / Excel here</h3> | |
| <p>or click to pick a file from your pack</p> | |
| <span class="filechip" id="fileChip">📄 <span id="fileName"></span></span> | |
| </label> | |
| <div class="panel"> | |
| <button class="btn btn-primary" id="cleanBtn" disabled>🧽 Clean it</button> | |
| <button class="btn btn-ghost" id="sampleBtn">🍂 Load the messy sample</button> | |
| <div class="hint">no file? grab <code>samples/dirty_contacts.csv</code></div> | |
| </div> | |
| </div> | |
| <div class="status" id="status" role="status" aria-live="polite"> | |
| <div class="scrubber"><div class="suds"></div><div class="brush">🧽</div></div> | |
| <span id="statusText">Scrubbing your data clean…</span> | |
| </div> | |
| </div> | |
| <!-- RESULTS --> | |
| <div class="results" id="results"> | |
| <div class="summary-card"> | |
| <div class="lbl">trail report</div> | |
| <p id="summaryText">—</p> | |
| </div> | |
| <div class="stats" id="stats"></div> | |
| <div class="tables"> | |
| <div class="tablecard"> | |
| <div class="head"><h4>Before</h4><span class="tag before">as uploaded</span></div> | |
| <div class="scroll"><div id="beforeTable"></div></div> | |
| </div> | |
| <div class="tablecard"> | |
| <div class="head"><h4>After</h4><span class="tag after">scrubbed</span></div> | |
| <div class="scroll"><div id="afterTable"></div></div> | |
| </div> | |
| </div> | |
| <span class="section-tab">📜 What we changed</span> | |
| <div class="report-card" id="report">—</div> | |
| <div class="download-row"> | |
| <button class="btn btn-download" id="downloadBtn">⬇️ Download scrubbed.csv</button> | |
| </div> | |
| </div> | |
| <footer> | |
| Built small for the trail <span class="star">✦</span> ScrubData keeps your original file untouched. | |
| </footer> | |
| </div> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client"; | |
| // ---- element refs ---- | |
| const fileInput = document.getElementById("fileInput"); | |
| const dropzone = document.getElementById("dropzone"); | |
| const fileChip = document.getElementById("fileChip"); | |
| const fileName = document.getElementById("fileName"); | |
| const cleanBtn = document.getElementById("cleanBtn"); | |
| const sampleBtn = document.getElementById("sampleBtn"); | |
| const statusEl = document.getElementById("status"); | |
| const statusText= document.getElementById("statusText"); | |
| const results = document.getElementById("results"); | |
| const summaryEl = document.getElementById("summaryText"); | |
| const statsEl = document.getElementById("stats"); | |
| const reportEl = document.getElementById("report"); | |
| const downloadBtn = document.getElementById("downloadBtn"); | |
| const SAMPLE_PATH = "samples/dirty_contacts.csv"; | |
| let selectedFile = null; // a File object OR a {url} handle for the sample | |
| let csvText = ""; | |
| // ---------- file selection ---------- | |
| function setFile(file, displayName) { | |
| selectedFile = file; | |
| fileName.textContent = displayName; | |
| fileChip.classList.add("show"); | |
| cleanBtn.disabled = false; | |
| } | |
| fileInput.addEventListener("change", e => { | |
| const f = e.target.files[0]; | |
| if (f) setFile(f, f.name); | |
| }); | |
| // drag & drop | |
| ["dragenter","dragover"].forEach(ev => | |
| dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.add("drag"); })); | |
| ["dragleave","drop"].forEach(ev => | |
| dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.remove("drag"); })); | |
| dropzone.addEventListener("drop", e => { | |
| const f = e.dataTransfer.files[0]; | |
| if (f) setFile(f, f.name); | |
| }); | |
| // ---------- sample loader ---------- | |
| sampleBtn.addEventListener("click", async () => { | |
| sampleBtn.disabled = true; | |
| const original = sampleBtn.innerHTML; | |
| sampleBtn.innerHTML = '<span class="spin">🍂</span> fetching sample…'; | |
| try { | |
| const res = await fetch(SAMPLE_PATH); | |
| if (!res.ok) throw new Error("sample not reachable (HTTP " + res.status + ")"); | |
| const blob = await res.blob(); | |
| const file = new File([blob], "dirty_contacts.csv", { type: "text/csv" }); | |
| setFile(file, "dirty_contacts.csv (sample)"); | |
| } catch (err) { | |
| // Fall back to passing the server-relative path directly to handle_file. | |
| setFile({ __samplePath: SAMPLE_PATH }, "dirty_contacts.csv (sample)"); | |
| } finally { | |
| sampleBtn.disabled = false; | |
| sampleBtn.innerHTML = original; | |
| } | |
| }); | |
| // ---------- clean action ---------- | |
| cleanBtn.addEventListener("click", runClean); | |
| async function runClean() { | |
| if (!selectedFile) return; | |
| showStatus("Scrubbing your data clean…", false); | |
| cleanBtn.disabled = true; | |
| results.classList.remove("show"); | |
| try { | |
| const client = await Client.connect(window.location.origin); | |
| const fileArg = selectedFile.__samplePath | |
| ? handle_file(selectedFile.__samplePath) | |
| : handle_file(selectedFile); | |
| const result = await client.predict("/clean_data", { file_path: fileArg }); | |
| const data = result?.data?.[0]; | |
| if (!data) throw new Error("Backend returned no data."); | |
| render(data); | |
| hideStatus(); | |
| results.classList.add("show"); | |
| results.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } catch (err) { | |
| console.error(err); | |
| showStatus("😖 Couldn't clean that file: " + (err?.message || err), true); | |
| } finally { | |
| cleanBtn.disabled = false; | |
| } | |
| } | |
| // ---------- status helpers ---------- | |
| function showStatus(msg, isError) { | |
| statusText.textContent = msg; | |
| statusEl.classList.toggle("err", !!isError); | |
| statusEl.classList.add("show"); | |
| // hide the scrubber animation in error state | |
| statusEl.querySelector(".scrubber").style.display = isError ? "none" : "block"; | |
| } | |
| function hideStatus() { statusEl.classList.remove("show"); } | |
| // ---------- rendering ---------- | |
| function render(data) { | |
| csvText = data.csv_text || ""; | |
| // summary | |
| summaryEl.textContent = data.summary || "Cleaning complete."; | |
| // tables | |
| const before = normalizeRecords(data.before); | |
| const after = normalizeRecords(data.after); | |
| document.getElementById("beforeTable").innerHTML = buildTable(before); | |
| document.getElementById("afterTable").innerHTML = buildTable(after); | |
| // quick stats | |
| const bRows = before.length, aRows = after.length; | |
| const bCols = before.length ? Object.keys(before[0]).length : 0; | |
| const aCols = after.length ? Object.keys(after[0]).length : 0; | |
| statsEl.innerHTML = [ | |
| stat(bRows, "rows in"), | |
| stat(aRows, "rows out"), | |
| stat(Math.max(0, bRows - aRows), "rows removed"), | |
| stat(aCols, "clean columns"), | |
| ].join(""); | |
| // report markdown | |
| reportEl.innerHTML = renderMarkdown(data.report_md || "_No report provided._"); | |
| } | |
| function stat(num, cap) { | |
| return `<div class="stat"><div class="num">${num}</div><div class="cap">${cap}</div></div>`; | |
| } | |
| // Accept either array-of-records or {columns, data} shapes. | |
| function normalizeRecords(payload) { | |
| if (!payload) return []; | |
| if (Array.isArray(payload)) return payload; | |
| if (payload.columns && Array.isArray(payload.data)) { | |
| return payload.data.map(row => { | |
| const o = {}; | |
| payload.columns.forEach((c, i) => (o[c] = row[i])); | |
| return o; | |
| }); | |
| } | |
| return []; | |
| } | |
| function buildTable(records) { | |
| if (!records.length) return '<p style="padding:18px;color:#b9ac90;">No rows.</p>'; | |
| const cols = Object.keys(records[0]); | |
| const MAX = 60; // cap rows rendered for performance | |
| const shown = records.slice(0, MAX); | |
| let html = "<table class='grid'><thead><tr>"; | |
| cols.forEach(c => (html += `<th>${esc(c)}</th>`)); | |
| html += "</tr></thead><tbody>"; | |
| shown.forEach(r => { | |
| html += "<tr>"; | |
| cols.forEach(c => { | |
| const v = r[c]; | |
| const empty = v === null || v === undefined || v === "" || (typeof v === "number" && isNaN(v)); | |
| html += `<td class="${empty ? "empty" : ""}">${empty ? "—" : esc(String(v))}</td>`; | |
| }); | |
| html += "</tr>"; | |
| }); | |
| html += "</tbody></table>"; | |
| if (records.length > MAX) { | |
| html += `<p style="padding:8px 12px;color:#8a8064;font-size:.78rem;">…showing first ${MAX} of ${records.length} rows.</p>`; | |
| } | |
| return html; | |
| } | |
| function esc(s) { | |
| return s.replace(/[&<>"']/g, m => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[m])); | |
| } | |
| // ---------- download ---------- | |
| downloadBtn.addEventListener("click", () => { | |
| if (!csvText) return; | |
| const blob = new Blob([csvText], { type: "text/csv;charset=utf-8;" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = "scrubbed.csv"; | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| // ---------- tiny markdown renderer ---------- | |
| // Supports headings, bold, inline code, bullet lists, simple GFM tables, hr. | |
| function renderMarkdown(md) { | |
| const lines = md.replace(/\r\n/g, "\n").split("\n"); | |
| let html = "", inList = false, i = 0; | |
| const inline = t => esc(t) | |
| .replace(/`([^`]+)`/g, "<code>$1</code>") | |
| .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") | |
| .replace(/\*([^*]+)\*/g, "<em>$1</em>"); | |
| const closeList = () => { if (inList) { html += "</ul>"; inList = false; } }; | |
| while (i < lines.length) { | |
| let line = lines[i]; | |
| // table block | |
| if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) { | |
| closeList(); | |
| const header = splitRow(line); | |
| i += 2; // skip separator | |
| let rows = []; | |
| while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(splitRow(lines[i])); i++; } | |
| html += "<table><thead><tr>" + header.map(h => `<th>${inline(h)}</th>`).join("") + "</tr></thead><tbody>"; | |
| rows.forEach(r => { html += "<tr>" + r.map(c => `<td>${inline(c)}</td>`).join("") + "</tr>"; }); | |
| html += "</tbody></table>"; | |
| continue; | |
| } | |
| if (/^\s*$/.test(line)) { closeList(); i++; continue; } | |
| if (/^---+$/.test(line.trim())) { closeList(); html += "<hr/>"; i++; continue; } | |
| let m; | |
| if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { | |
| closeList(); | |
| const lvl = Math.min(m[1].length, 3); | |
| html += `<h${lvl}>${inline(m[2])}</h${lvl}>`; | |
| } else if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) { | |
| if (!inList) { html += "<ul>"; inList = true; } | |
| html += `<li>${inline(m[1])}</li>`; | |
| } else { | |
| closeList(); | |
| html += `<p>${inline(line)}</p>`; | |
| } | |
| i++; | |
| } | |
| closeList(); | |
| return html; | |
| } | |
| function splitRow(line) { | |
| return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(s => s.trim()); | |
| } | |
| </script> | |
| </body> | |
| </html> | |