// app.js — frontend logic for IPFS Web Uploader (EGAZ / ETI) const $ = (id) => document.getElementById(id); const log = (msg) => { const el = $("log"); const now = new Date().toISOString().replace("T"," ").replace("Z",""); el.textContent += `[${now}] ${msg}\n`; el.scrollTop = el.scrollHeight; }; let CFG = null; let RATES = null; let CURRENT_QUOTE = null; let CURRENT_TX = null; let POLL_TIMER = null; let SELECTED_ACCOUNT = null; let UPLOADED_CID = null; async function api(path, opts) { const res = await fetch(path, opts); if (!res.ok) throw new Error(`${path} HTTP ${res.status}`); return res.json(); } // ===== helpers ===== function updateWalletUI() { const btn = $("btnConnectTop"); const acctEl = $("tbAcct"); if (!SELECTED_ACCOUNT) { btn.disabled = !window.ethereum; btn.textContent = window.ethereum ? "MetaMask Login" : "No MetaMask"; acctEl.textContent = "wallet: not connected"; } else { btn.disabled = false; btn.textContent = "MetaMask Logout"; acctEl.textContent = `wallet: ${SELECTED_ACCOUNT}`; } } function middleEllipsis(addr, head=14, tail=12){ if (!addr) return "–"; if (addr.length <= head+tail) return addr; return addr.slice(0, head) + "…" + addr.slice(-tail); } function fmtDec(decStr, digits=6){ const [i,f=""] = String(decStr).split("."); const cut = f.slice(0, digits).replace(/0+$/,""); return cut ? `${i}.${cut}` : i; } function bytesHuman(n){ const b=Number(n); if(b<1024) return `${b} B`; const u=['KB','MB','GB','TB']; let i=-1,x=b; do{x/=1024;i++;}while(x>=1024&&i { const cur = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; const next = cur === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; try { localStorage.setItem("theme", next); } catch {} }; // ===== config/health/rates ===== async function loadConfigAndHealth(){ CFG = await api("/api/config/public"); $("cfgChain").textContent = CFG.chain; $("cfgRpc").textContent = CFG.rpc; $("cfgPayTo").textContent = CFG.payTo; $("cfgTier").textContent = `${CFG.tierMB} MB`; $("cfgPpt").textContent = `${CFG.pricePerTierEgaz} ${CFG.currencyBase || "EGAZ"}`; $("cfgCurrencies").textContent = (CFG.supportedCurrencies||["EGAZ"]).join(", "); $("cfgConf").textContent = CFG.requiredConfirmations ?? "3"; $("confReq").textContent = CFG.requiredConfirmations ?? "3"; const h = await api("/api/health"); $("healthBlock").textContent = h.latestBlock; $("healthReq").textContent = h.requiredConfirmations; $("healthNow").textContent = h.now; log(`RPC ok, latest=${h.latestBlock}`); try{ RATES = await api("/api/rates"); $("rateSrc").textContent = RATES.source || "fallback"; $("rateEgazUsd").textContent = RATES.egazUsd ? `$${RATES.egazUsd}` : "–"; $("rateEtiUsd").textContent = RATES.etiUsd ? `$${RATES.etiUsd}` : "–"; $("rateEtiPerEgaz").textContent = RATES.etiPerEgaz ? `${RATES.etiPerEgaz.toFixed(6)} ETI` : "–"; log(`Rates source=${RATES.source} egazUsd=${RATES.egazUsd||0} etiUsd=${RATES.etiUsd||0} etiPerEgaz=${RATES.etiPerEgaz||1}`); }catch(e){ $("rateSrc").textContent = "unavailable"; $("rateEgazUsd").textContent = "–"; $("rateEtiUsd").textContent = "–"; $("rateEtiPerEgaz").textContent = "–"; log(`rates error: ${e.message}`); } } // ===== wallet ===== async function ensureWallet(){ if (!window.ethereum) { $("tbAcct").textContent = "wallet: no metamask"; $("btnConnectTop").disabled = true; $("btnConnectTop").textContent = "No MetaMask"; return; } const accts = await ethereum.request({ method:"eth_accounts" }); if (accts && accts.length){ setAccount(accts[0]); } ethereum.on?.("accountsChanged", (acc) => setAccount(acc[0] || null)); } function setAccount(a){ SELECTED_ACCOUNT = a || null; $("tbAcct").textContent = a ? `wallet: ${middleEllipsis(a)}` : "wallet: disconnected"; if (a){ $("btnConnectTop").textContent = "Connected"; $("btnConnectTop").disabled = true; loadFiles(); } else { $("btnConnectTop").textContent = "Connect MetaMask"; $("btnConnectTop").disabled = false; renderFiles([]); } } $("btnConnectTop").onclick = async ()=>{ if (!window.ethereum){ alert("MetaMask not found"); return; } const acc = await ethereum.request({ method:"eth_requestAccounts" }); setAccount(acc[0] || null); }; // ===== files panel ===== async function loadFiles(){ try{ $("filesStatus").textContent = "loading…"; const addr = SELECTED_ACCOUNT || "0x0000000000000000000000000000000000000000"; const res = await api(`/api/files?address=${addr}`); renderFiles(res.files || []); $("filesStatus").textContent = `showing ${(res.files||[]).length} file(s) for ${middleEllipsis(addr)}`; }catch(e){ $("filesStatus").textContent = `error: ${e.message}`; renderFiles([]); } } function renderFiles(files){ const wrap = $("filesTableWrap"); const empty = $("filesEmpty"); const tbody = $("filesTbody"); tbody.innerHTML = ""; if (!files || files.length === 0){ empty.classList.remove("hidden"); wrap.classList.add("hidden"); return; } empty.classList.add("hidden"); wrap.classList.remove("hidden"); for (const f of files){ const tr = document.createElement("tr"); const size = f.size_bytes ?? f.sizeBytes ?? 0; const cid = f.cid || ""; const path = f.path || ""; const label = f.label || ""; const when = f.created_at || f.createdAt || ""; const status = f.status || "stored"; tr.innerHTML = ` ${f.filename || "—"} ${bytesHuman(size)} ${cid || "—"} ${path || "—"} ${label || "—"} ${status} ${when ? new Date(when).toLocaleString() : "—"} `; tbody.appendChild(tr); } } $("btnRefreshFiles").onclick = loadFiles; // ===== quote & pay & upload ===== function resetQuoteUI(){ $("quoteBox").classList.add("hidden"); $("confirmBox").classList.add("hidden"); $("quoteStatus").textContent = ""; $("payStatus").textContent = ""; $("btnUploadNow").classList.add("hidden"); CURRENT_QUOTE = null; CURRENT_TX = null; UPLOADED_CID = null; if (POLL_TIMER) { clearInterval(POLL_TIMER); POLL_TIMER = null; } } $("btnQuote").onclick = async ()=>{ try{ resetQuoteUI(); const f = $("file").files[0]; if (!f){ alert("Choose a file"); return; } $("quoteStatus").textContent = "requesting quote…"; const body = { filename: f.name, sizeBytes: f.size, address: SELECTED_ACCOUNT || "0x0000000000000000000000000000000000000000", path: $("path").value || null }; const q = await api("/api/quote", { method: "POST", headers: { "content-type":"application/json" }, body: JSON.stringify(body) }); CURRENT_QUOTE = q; $("qId").textContent = q.quoteId; $("qFile").textContent = q.filename; $("qSize").textContent = bytesHuman(q.sizeBytes); $("qTiers").textContent = q.tiers; $("qTierMb").textContent = `${q.tierMB} MB`; $("qEgaz").textContent = `${fmtDec(q.priceEgaz)} EGAZ`; $("qEti").textContent = `${fmtDec(q.priceEti)} ETI`; $("qPayTo").textContent = q.payTo; $("qExp").textContent = new Date(q.expiresAt).toLocaleString(); $("quoteBox").classList.remove("hidden"); $("quoteStatus").textContent = "quote ready"; log(`quote ${q.quoteId} priceEgaz=${q.priceEgaz} priceEti=${q.priceEti}`); }catch(e){ $("quoteStatus").textContent = `error: ${e.message}`; log(`quote error: ${e.stack||e}`); } }; $("btnPay").onclick = async ()=>{ try{ if (!CURRENT_QUOTE) { alert("Get a quote first"); return; } if (!window.ethereum) { alert("MetaMask not found"); return; } if (!SELECTED_ACCOUNT){ const acc = await ethereum.request({ method:"eth_requestAccounts" }); setAccount(acc[0] || null); if (!SELECTED_ACCOUNT) throw new Error("no account"); } const sel = $("currencySel").value; // "EGAZ" or "ETI" const payTo = CURRENT_QUOTE.payTo; let txParams = { from: SELECTED_ACCOUNT }; if (sel === "EGAZ"){ const wei = parseUnits(CURRENT_QUOTE.priceEgaz, 18); txParams.to = payTo; txParams.value = to0xHex(wei); txParams.data = "0x"; log(`sending EGAZ value=${CURRENT_QUOTE.priceEgaz} -> ${payTo}`); } else { const token = CFG.etiContract || "0x34c61EA91bAcdA647269d4e310A86b875c09946f"; const wei = parseUnits(CURRENT_QUOTE.priceEti, 18); txParams.to = token; txParams.value = "0x0"; txParams.data = encodeErc20Transfer(payTo, wei); log(`sending ETI token=${token} amount=${CURRENT_QUOTE.priceEti} -> ${payTo}`); } $("payStatus").textContent = "opening MetaMask…"; const txHash = await ethereum.request({ method: "eth_sendTransaction", params: [txParams] }); CURRENT_TX = txHash; $("payStatus").textContent = `tx sent: ${middleEllipsis(txHash)}`; log(`tx sent ${txHash}`); const rec = await api("/api/payments/record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ quoteId: String(CURRENT_QUOTE.quoteId), txHash }) }); log(`payments/record ok currency=${rec.currency||"?"}`); $("confirmBox").classList.remove("hidden"); $("confTx").textContent = txHash; $("confReq").textContent = CFG.requiredConfirmations ?? 3; pollStatusStart(Number(CURRENT_QUOTE.quoteId)); }catch(e){ $("payStatus").textContent = `error: ${e.message}`; log(`pay error: ${e.stack||e}`); } }; async function uploadNow(){ try{ const f = $("file").files[0]; if (!f){ alert("Choose a file"); return; } if (!CURRENT_QUOTE){ alert("Get quote & pay first"); return; } const form = new FormData(); form.append("file", f); form.append("quoteId", String(CURRENT_QUOTE.quoteId)); form.append("address", SELECTED_ACCOUNT || ""); if ($("path").value) form.append("path", $("path").value); if ($("label").value) form.append("label", $("label").value); $("payStatus").textContent = "uploading to IPFS…"; const res = await fetch("/api/upload", { method:"POST", body: form }); if (!res.ok) throw new Error(`/api/upload HTTP ${res.status}`); const j = await res.json(); if (!j.ok) throw new Error(j.error || "upload failed"); UPLOADED_CID = j.cid; $("payStatus").textContent = `uploaded: ${j.cid}`; log(`upload ok cid=${j.cid}`); await loadFiles(); }catch(e){ $("payStatus").textContent = `upload error: ${e.message}`; log(`upload error: ${e.stack||e}`); } } $("btnUploadNow").onclick = uploadNow; async function pollOnce(quoteId){ try{ const st = await api(`/api/payments/status?quoteId=${quoteId}`); $("confStatus").textContent = st.status; $("confCount").textContent = st.confs; if (st.txHash) $("confTx").textContent = st.txHash; if (st.status === "paid"){ log(`quote ${quoteId} paid (confs ${st.confs}/${st.required})`); clearInterval(POLL_TIMER); POLL_TIMER = null; $("btnUploadNow").classList.remove("hidden"); // Auto-upload if a file is selected and not uploaded yet if ($("file").files[0] && !UPLOADED_CID){ uploadNow(); } } }catch(e){ log(`status poll error: ${e.message}`); } } function pollStatusStart(quoteId){ if (POLL_TIMER) clearInterval(POLL_TIMER); pollOnce(quoteId); POLL_TIMER = setInterval(()=>pollOnce(quoteId), 5000); } // ===== init ===== (async function init(){ try{ await loadConfigAndHealth(); await ensureWallet(); await loadFiles(); }catch(e){ log(`init error: ${e.message}`); } })();