ipfs storage for images and other nontext items. for use with etica - runs on etica network and currencys
https://collect.etica-stats.org
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
351 lines
12 KiB
351 lines
12 KiB
// 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<u.length-1); |
|
return `${x.toFixed(2)} ${u[i]}`; |
|
} |
|
function parseUnits(decStr, decimals=18){ |
|
if (typeof decStr !== "string") decStr = String(decStr); |
|
const neg=decStr.startsWith("-"); if(neg) decStr=decStr.slice(1); |
|
if(!/^\d+(\.\d+)?$/.test(decStr)) throw new Error("bad decimal"); |
|
const [ip,frRaw=""]=decStr.split("."); |
|
const fr=(frRaw+"0".repeat(decimals)).slice(0,decimals); |
|
const bi=BigInt(ip+fr.replace(/^0+$/,"")); |
|
return neg?-bi:bi; |
|
} |
|
function to0xHex(big){ return "0x" + big.toString(16); } |
|
function encodeErc20Transfer(to, amountWei){ |
|
const method="a9059cbb"; |
|
const addr=to.toLowerCase().replace(/^0x/,"").padStart(64,"0"); |
|
const amt=amountWei.toString(16).padStart(64,"0"); |
|
return "0x"+method+addr+amt; |
|
} |
|
|
|
// ===== theme toggle ===== |
|
$("btnTheme").onclick = () => { |
|
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 = ` |
|
<td>${f.filename || "—"}</td> |
|
<td>${bytesHuman(size)}</td> |
|
<td class="mono">${cid || "—"}</td> |
|
<td class="mono">${path || "—"}</td> |
|
<td>${label || "—"}</td> |
|
<td><span class="tag">${status}</span></td> |
|
<td class="small">${when ? new Date(when).toLocaleString() : "—"}</td> |
|
`; |
|
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}`); |
|
} |
|
})();
|
|
|