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.
283 lines
9.3 KiB
283 lines
9.3 KiB
// collect — frontend |
|
|
|
const $ = (id) => document.getElementById(id); |
|
const log = (msg) => { |
|
const el = $("log"); |
|
if (!el) return; |
|
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 SELECTED_ACCOUNT = null; |
|
|
|
// ===== utils ===== |
|
function bytesHuman(n=0){ |
|
const u = ["B","KB","MB","GB","TB"]; |
|
let i = 0; let v = Number(n||0); |
|
while (v >= 1024 && i < u.length-1){ v/=1024; i++; } |
|
return `${v.toFixed(i?2:0)} ${u[i]}`; |
|
} |
|
function middleEllipsis(s="", keep=6){ |
|
s = String(s); if (s.length <= keep*2) return s; |
|
return `${s.slice(0,keep)}…${s.slice(-keep)}`; |
|
} |
|
async function api(path, opt={}){ |
|
const res = await fetch(path, { ...opt, headers: { "content-type":"application/json", ...(opt.headers||{}) }}); |
|
if (!res.ok) throw new Error(`${path} ${res.status}`); |
|
return res.json(); |
|
} |
|
function isImageFilename(name = "") { |
|
const n = name.toLowerCase(); |
|
return n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || |
|
n.endsWith(".gif") || n.endsWith(".webp") || n.endsWith(".bmp") || |
|
n.endsWith(".svg"); |
|
} |
|
function ipfsUrlFromPath(path) { |
|
if (!path) return ""; |
|
const gw = (CFG && CFG.gateway) ? CFG.gateway.replace(/\/+$/,"") : ""; |
|
return gw ? `${gw}${path}` : path; |
|
} |
|
|
|
// ===== wallet UI ===== |
|
function updateWalletUI() { |
|
const btn = $("btnConnectTop"); |
|
const acctEl = $("tbAcct"); |
|
if (!btn || !acctEl) return; |
|
|
|
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}`; |
|
} |
|
} |
|
|
|
$("btnConnectTop").addEventListener("click", async () => { |
|
try { |
|
if (!window.ethereum) { updateWalletUI(); return; } |
|
if (!SELECTED_ACCOUNT) { |
|
const accounts = await ethereum.request({ method: "eth_requestAccounts" }); |
|
SELECTED_ACCOUNT = (accounts && accounts[0]) ? accounts[0] : null; |
|
log(`✅ Login ${SELECTED_ACCOUNT || "(none)"}`); |
|
updateWalletUI(); |
|
if (SELECTED_ACCOUNT) await loadFiles(); |
|
} else { |
|
log("👋 Logout"); |
|
SELECTED_ACCOUNT = null; |
|
CURRENT_QUOTE = null; |
|
CURRENT_TX = null; |
|
updateWalletUI(); |
|
await loadFiles(); |
|
} |
|
} catch (e) { |
|
log(`wallet toggle error: ${e.message}`); |
|
} |
|
}); |
|
|
|
// ===== config & health & rates ===== |
|
async function loadConfigAndHealth(){ |
|
CFG = await api("/api/config/public"); |
|
$("cfgChain").textContent = CFG.chain || "–"; |
|
$("cfgRpc").textContent = CFG.rpc || "–"; |
|
$("cfgPayTo").textContent = CFG.payTo || "–"; |
|
$("cfgGateway").textContent = CFG.gateway || "–"; |
|
$("cfgTier").textContent = (CFG.tierBytes ? `${Math.round(CFG.tierBytes/1024/1024)} MB` : "–"); |
|
$("cfgPpt").textContent = (CFG.priceEgazPerTier ? `${CFG.priceEgazPerTier} ${CFG.currencyBase||"EGAZ"}` : "–"); |
|
$("cfgConf").textContent = CFG.requiredConfirmations ?? "–"; |
|
|
|
const h = await api("/api/health"); |
|
$("healthBlock").textContent = h.latestBlock ?? "–"; |
|
$("healthReq").textContent = h.requiredConfirmations ?? "–"; |
|
$("healthNow").textContent = h.now ?? "–"; |
|
|
|
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 src=${RATES.source||"?"} egazUsd=${RATES.egazUsd||0} etiUsd=${RATES.etiUsd||0}`); |
|
}catch(e){ |
|
$("rateSrc").textContent = "unavailable"; |
|
$("rateEgazUsd").textContent = "–"; |
|
$("rateEtiUsd").textContent = "–"; |
|
$("rateEtiPerEgaz").textContent = "–"; |
|
log(`rates error: ${e.message}`); |
|
} |
|
} |
|
|
|
// ===== files ===== |
|
async function loadFiles(){ |
|
const status = $("filesStatus"); |
|
try{ |
|
status.textContent = "loading…"; |
|
const addr = SELECTED_ACCOUNT || "0x0000000000000000000000000000000000000000"; |
|
const res = await api(`/api/files?address=${addr}`); |
|
const files = res.files || []; |
|
renderFiles(files); |
|
renderGallery(files); |
|
status.textContent = `showing ${files.length} file(s) for ${middleEllipsis(addr)}`; |
|
// recent preview |
|
const recent = files[0]; |
|
const box = $("recentBox"); |
|
const who = $("recentFor"); |
|
who.textContent = `Showing files for ${middleEllipsis(addr)}`; |
|
if (!recent){ box.textContent = "No preview"; return; } |
|
const href = ipfsUrlFromPath(recent.path || ""); |
|
if (href && isImageFilename(recent.filename||"")){ |
|
box.innerHTML = `<a href="${href}" target="_blank" rel="noopener"><img src="${href}" alt=""></a>`; |
|
} else { |
|
box.innerHTML = `<a href="${href}" target="_blank" rel="noopener" class="small" style="color:#98a2b3">${recent.filename||"file"}</a>`; |
|
} |
|
}catch(e){ |
|
status.textContent = `error: ${e.message}`; |
|
renderFiles([]); |
|
renderGallery([]); |
|
} |
|
} |
|
|
|
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 small">${cid || "—"}</td> |
|
<td class="mono small">${path || "—"}</td> |
|
<td>${label || "—"}</td> |
|
<td><span class="tag">${status}</span></td> |
|
<td class="small">${when ? new Date(when).toLocaleString() : "—"}</td> |
|
`; |
|
tbody.appendChild(tr); |
|
} |
|
} |
|
|
|
function renderGallery(files){ |
|
const wrap = $("gallery"); |
|
const status = $("galleryStatus"); |
|
wrap.innerHTML = ""; |
|
if (!files || files.length === 0){ |
|
status.textContent = "No files yet."; |
|
return; |
|
} |
|
status.textContent = `Showing ${files.length} file(s).`; |
|
|
|
for (const f of files){ |
|
const card = document.createElement("div"); |
|
card.className = "card-thumb"; |
|
|
|
const head = document.createElement("div"); |
|
head.className = "small"; |
|
head.style.display = "flex"; |
|
head.style.justifyContent = "space-between"; |
|
head.style.gap = "8px"; |
|
head.innerHTML = ` |
|
<span class="mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:70%;">${f.filename || "—"}</span> |
|
<span class="tag">${(f.currency || "EGAZ").toUpperCase()}</span> |
|
`; |
|
|
|
const media = document.createElement("div"); |
|
media.className = "thumb-box"; |
|
|
|
const link = document.createElement("a"); |
|
const href = ipfsUrlFromPath(f.path || ""); |
|
link.href = href || "#"; |
|
link.target = "_blank"; |
|
link.rel = "noopener"; |
|
|
|
if (href && isImageFilename(f.filename || "")) { |
|
const img = document.createElement("img"); |
|
img.src = href; |
|
img.alt = f.filename || "image"; |
|
img.loading = "lazy"; |
|
img.onerror = () => { |
|
media.innerHTML = `<div class="small" style="color:#98a2b3;text-align:center;padding:12px;"> |
|
Preview unavailable<br/><span class="mono">${(f.path||"").slice(0,40)}…</span> |
|
</div>`; |
|
}; |
|
link.appendChild(img); |
|
} else { |
|
link.innerHTML = `<div class="small" style="color:#98a2b3;text-align:center;padding:12px;"> |
|
No preview<br/><span class="mono">${(f.path||"").slice(0,40)}…</span> |
|
</div>`; |
|
} |
|
media.appendChild(link); |
|
|
|
const foot = document.createElement("div"); |
|
foot.className = "small"; |
|
foot.style.display = "flex"; |
|
foot.style.justifyContent = "space-between"; |
|
const size = f.size_bytes ?? f.sizeBytes ?? 0; |
|
const when = f.created_at || f.createdAt || ""; |
|
foot.innerHTML = ` |
|
<span>${bytesHuman(size)}</span> |
|
<span>${when ? new Date(when).toLocaleString() : ""}</span> |
|
`; |
|
|
|
card.appendChild(head); |
|
card.appendChild(media); |
|
card.appendChild(foot); |
|
wrap.appendChild(card); |
|
} |
|
} |
|
|
|
// ===== wallet ensure ===== |
|
async function ensureWallet(){ |
|
updateWalletUI(); |
|
|
|
if (!window.ethereum) return; |
|
|
|
const accts = await ethereum.request({ method: "eth_accounts" }); |
|
SELECTED_ACCOUNT = (accts && accts[0]) ? accts[0] : null; |
|
updateWalletUI(); |
|
|
|
ethereum.removeAllListeners?.("accountsChanged"); |
|
ethereum.on?.("accountsChanged", async (accounts) => { |
|
SELECTED_ACCOUNT = (accounts && accounts[0]) ? accounts[0] : null; |
|
updateWalletUI(); |
|
await loadFiles(); |
|
}); |
|
|
|
ethereum.removeAllListeners?.("chainChanged"); |
|
ethereum.on?.("chainChanged", async (_chainId) => { |
|
await loadConfigAndHealth(); |
|
await loadFiles(); |
|
}); |
|
} |
|
|
|
// ===== init ===== |
|
(async function init(){ |
|
try{ |
|
updateWalletUI(); // immediate correct button label |
|
await loadConfigAndHealth(); |
|
await ensureWallet(); |
|
await loadFiles(); |
|
}catch(e){ |
|
log(`init error: ${e.message}`); |
|
} |
|
})();
|
|
|