@decentnetwork/beagle 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/desktop/app.js +193 -5
- package/dist/server.js +183 -2
- package/package.json +1 -1
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
1
|
+
window.__DK_UI_VERSION="0.1.21";
|
|
2
2
|
const ICON_PATHS = {
|
|
3
3
|
// ---- tab bar (the four must feel like one set) ----
|
|
4
4
|
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
|
@@ -2800,7 +2800,97 @@ function DkEditModal({ T, me, onClose, onSave }) {
|
|
|
2800
2800
|
};
|
|
2801
2801
|
return /* @__PURE__ */ React.createElement("div", { onClick: onClose, style: { position: "fixed", inset: 0, zIndex: 90, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 } }, /* @__PURE__ */ React.createElement("div", { onClick: (e) => e.stopPropagation(), style: { width: 440, maxWidth: "92vw", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: 22, display: "flex", flexDirection: "column", gap: 14 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700, color: "var(--text)" } }, T.editProfile), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { icon: "x", size: "sm", onClick: onClose })), /* @__PURE__ */ React.createElement("label", { style: { display: "flex", flexDirection: "column", gap: 6 } }, /* @__PURE__ */ React.createElement("span", { style: lbl }, "display name"), /* @__PURE__ */ React.createElement("input", { value: name, onChange: (e) => setName(e.target.value), onKeyDown: onKey, autoFocus: true, maxLength: 48, style: field })), /* @__PURE__ */ React.createElement("label", { style: { display: "flex", flexDirection: "column", gap: 6 } }, /* @__PURE__ */ React.createElement("span", { style: lbl }, "status message"), /* @__PURE__ */ React.createElement("input", { value: desc, onChange: (e) => setDesc(e.target.value), onKeyDown: onKey, maxLength: 120, placeholder: "optional \u2014 a short bio friends will see", style: field })), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, "Your userid (the unique identity) can't change \u2014 only the display name + status."), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 2 } }, /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: onClose }, T.cancel || "cancel"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", onClick: save }, T.save || "save"))));
|
|
2802
2802
|
}
|
|
2803
|
-
|
|
2803
|
+
const dkPunkCache = /* @__PURE__ */ new Map();
|
|
2804
|
+
function DkPunkAvatar({ id, size, radius, fallbackSeed }) {
|
|
2805
|
+
const [img, setImg] = React.useState(() => (dkPunkCache.get(id) || {}).image || null);
|
|
2806
|
+
React.useEffect(() => {
|
|
2807
|
+
let dead = false;
|
|
2808
|
+
const hit = dkPunkCache.get(id);
|
|
2809
|
+
if (hit) {
|
|
2810
|
+
setImg(hit.image || null);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
setImg(null);
|
|
2814
|
+
fetch(`/api/punk/${id}`).then((r) => r.json()).then((d) => {
|
|
2815
|
+
if (d.ok && d.punk && d.punk.image) {
|
|
2816
|
+
dkPunkCache.set(id, d.punk);
|
|
2817
|
+
if (!dead)
|
|
2818
|
+
setImg(d.punk.image);
|
|
2819
|
+
}
|
|
2820
|
+
}).catch(() => {
|
|
2821
|
+
});
|
|
2822
|
+
return () => {
|
|
2823
|
+
dead = true;
|
|
2824
|
+
};
|
|
2825
|
+
}, [id]);
|
|
2826
|
+
if (!img)
|
|
2827
|
+
return /* @__PURE__ */ React.createElement(DkIdenticon, { seed: fallbackSeed || String(id), size, radius });
|
|
2828
|
+
return /* @__PURE__ */ React.createElement(
|
|
2829
|
+
"img",
|
|
2830
|
+
{
|
|
2831
|
+
src: img,
|
|
2832
|
+
alt: "",
|
|
2833
|
+
width: size,
|
|
2834
|
+
height: size,
|
|
2835
|
+
style: { width: size, height: size, borderRadius: radius, background: "#638596", imageRendering: "pixelated", flexShrink: 0 }
|
|
2836
|
+
}
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
function DkPunkPicker({ T, onPick, onClose }) {
|
|
2840
|
+
const TYPES = ["any", "female", "male", "zombie", "ape", "alien"];
|
|
2841
|
+
const tLabel = (t) => T && T["pt_" + t] || t;
|
|
2842
|
+
const [type, setType] = React.useState("any");
|
|
2843
|
+
const [batch, setBatch] = React.useState(null);
|
|
2844
|
+
const [err, setErr] = React.useState(null);
|
|
2845
|
+
const [gen, setGen] = React.useState(0);
|
|
2846
|
+
React.useEffect(() => {
|
|
2847
|
+
let dead = false;
|
|
2848
|
+
setBatch(null);
|
|
2849
|
+
setErr(null);
|
|
2850
|
+
fetch(`/api/punk-list?type=${type}&limit=24`).then((r) => r.json()).then((d) => {
|
|
2851
|
+
if (dead)
|
|
2852
|
+
return;
|
|
2853
|
+
if (d.ok)
|
|
2854
|
+
setBatch(d.list || []);
|
|
2855
|
+
else
|
|
2856
|
+
setErr(d.error || "load failed");
|
|
2857
|
+
}).catch((e) => {
|
|
2858
|
+
if (!dead)
|
|
2859
|
+
setErr(String(e && e.message || e));
|
|
2860
|
+
});
|
|
2861
|
+
return () => {
|
|
2862
|
+
dead = true;
|
|
2863
|
+
};
|
|
2864
|
+
}, [type, gen]);
|
|
2865
|
+
return /* @__PURE__ */ React.createElement("div", { onClick: onClose, style: { position: "fixed", inset: 0, zIndex: 90, background: "color-mix(in oklab, #000, transparent 38%)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 } }, /* @__PURE__ */ React.createElement("div", { onClick: (e) => e.stopPropagation(), style: { width: 480, maxWidth: "94vw", background: "var(--panel)", border: "1px solid var(--line)", borderRadius: 16, padding: 20, display: "flex", flexDirection: "column", gap: 14 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 13, fontWeight: 700, color: "var(--text)" } }, T.ensPickPunk || "choose a punk"), /* @__PURE__ */ React.createElement("div", { style: { flex: 1 } }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", onClick: () => setGen(gen + 1) }, T.ensShuffle || "shuffle"), /* @__PURE__ */ React.createElement(Btn, { icon: "x", size: "sm", onClick: onClose })), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" } }, TYPES.map((t) => /* @__PURE__ */ React.createElement(
|
|
2866
|
+
"button",
|
|
2867
|
+
{
|
|
2868
|
+
key: t,
|
|
2869
|
+
onClick: () => setType(t),
|
|
2870
|
+
style: {
|
|
2871
|
+
padding: "4px 10px",
|
|
2872
|
+
borderRadius: 999,
|
|
2873
|
+
border: "1px solid var(--line)",
|
|
2874
|
+
cursor: "pointer",
|
|
2875
|
+
background: type === t ? "var(--accent)" : "transparent",
|
|
2876
|
+
color: type === t ? "#fff" : "var(--dim)",
|
|
2877
|
+
fontFamily: "var(--ui)",
|
|
2878
|
+
fontSize: 12
|
|
2879
|
+
}
|
|
2880
|
+
},
|
|
2881
|
+
tLabel(t)
|
|
2882
|
+
))), /* @__PURE__ */ React.createElement("div", { style: { minHeight: 232 } }, err ? /* @__PURE__ */ React.createElement("div", { style: { padding: 16, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, err) : !batch ? /* @__PURE__ */ React.createElement("div", { style: { padding: 16, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement("div", { style: { display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 } }, batch.map((p) => /* @__PURE__ */ React.createElement(
|
|
2883
|
+
"button",
|
|
2884
|
+
{
|
|
2885
|
+
key: p.id,
|
|
2886
|
+
title: `#${p.id} \xB7 ${p.type}`,
|
|
2887
|
+
onClick: () => onPick(Number(p.id)),
|
|
2888
|
+
style: { padding: 0, border: "1px solid var(--line)", borderRadius: 10, cursor: "pointer", background: "#638596", overflow: "hidden", aspectRatio: "1" }
|
|
2889
|
+
},
|
|
2890
|
+
/* @__PURE__ */ React.createElement("img", { src: p.image, alt: `punk #${p.id}`, style: { width: "100%", height: "100%", imageRendering: "pixelated", display: "block" } })
|
|
2891
|
+
))))));
|
|
2892
|
+
}
|
|
2893
|
+
function DkEnsCard({ T, me, onRecord }) {
|
|
2804
2894
|
const [st, setSt] = React.useState({ loading: true });
|
|
2805
2895
|
const [label, setLabel] = React.useState("");
|
|
2806
2896
|
const [busy, setBusy] = React.useState(false);
|
|
@@ -2810,6 +2900,8 @@ function DkEnsCard({ T, me }) {
|
|
|
2810
2900
|
setSt({ loading: false, registered: !!d.registered, record: d.record || null, mineOwned: !!d.mineOwned });
|
|
2811
2901
|
if (d.registered && d.record && d.record.name)
|
|
2812
2902
|
setLabel(d.record.name.replace(/\.beagles\.eth$/i, ""));
|
|
2903
|
+
if (onRecord)
|
|
2904
|
+
onRecord(d.record || null);
|
|
2813
2905
|
}).catch((e) => setSt({ loading: false, error: String(e && e.message || e) }));
|
|
2814
2906
|
};
|
|
2815
2907
|
React.useEffect(() => {
|
|
@@ -2864,9 +2956,67 @@ function DkEnsCard({ T, me }) {
|
|
|
2864
2956
|
};
|
|
2865
2957
|
const field = { flex: 1, minWidth: 0, height: 34, borderRadius: 8, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--mono)", fontSize: 13, padding: "0 10px", outline: "none" };
|
|
2866
2958
|
const row = { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--line)" };
|
|
2959
|
+
const [picking, setPicking] = React.useState(false);
|
|
2960
|
+
const fileRef = React.useRef(null);
|
|
2867
2961
|
const rec = st.record;
|
|
2868
2962
|
const boundEth = rec && rec.addresses && rec.addresses["60"];
|
|
2869
2963
|
const boundSol = rec && rec.addresses && rec.addresses["501"];
|
|
2964
|
+
const punkId = rec && rec.nft === "CryptoPunks" && rec.nftid > 0 ? rec.nftid : null;
|
|
2965
|
+
const upAvatar = rec && rec.texts && rec.texts.avatar || null;
|
|
2966
|
+
const pickPunk = (id) => {
|
|
2967
|
+
setPicking(false);
|
|
2968
|
+
post("/api/ens-avatar", { nftid: id });
|
|
2969
|
+
};
|
|
2970
|
+
const onUploadFile = (e) => {
|
|
2971
|
+
const f = e.target.files && e.target.files[0];
|
|
2972
|
+
e.target.value = "";
|
|
2973
|
+
if (!f)
|
|
2974
|
+
return;
|
|
2975
|
+
setBusy(true);
|
|
2976
|
+
setMsg(null);
|
|
2977
|
+
const img = new Image();
|
|
2978
|
+
const url = URL.createObjectURL(f);
|
|
2979
|
+
const fail = (text) => {
|
|
2980
|
+
setBusy(false);
|
|
2981
|
+
setMsg({ tone: "err", text });
|
|
2982
|
+
};
|
|
2983
|
+
img.onload = () => {
|
|
2984
|
+
URL.revokeObjectURL(url);
|
|
2985
|
+
try {
|
|
2986
|
+
const S = 256;
|
|
2987
|
+
const c = document.createElement("canvas");
|
|
2988
|
+
c.width = S;
|
|
2989
|
+
c.height = S;
|
|
2990
|
+
const ctx = c.getContext("2d");
|
|
2991
|
+
const side = Math.min(img.width, img.height);
|
|
2992
|
+
ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S);
|
|
2993
|
+
const enc = (q) => {
|
|
2994
|
+
let d = c.toDataURL("image/webp", q);
|
|
2995
|
+
if (!d.startsWith("data:image/webp"))
|
|
2996
|
+
d = c.toDataURL("image/jpeg", q);
|
|
2997
|
+
return d;
|
|
2998
|
+
};
|
|
2999
|
+
let dataUrl = null;
|
|
3000
|
+
for (const q of [0.9, 0.75, 0.55, 0.4]) {
|
|
3001
|
+
dataUrl = enc(q);
|
|
3002
|
+
if (dataUrl.length < 13e4)
|
|
3003
|
+
break;
|
|
3004
|
+
}
|
|
3005
|
+
if (!dataUrl || dataUrl.length >= 13e4) {
|
|
3006
|
+
fail(T.ensTooBig || "image too large even after scaling");
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
post("/api/ens-avatar-upload", { dataUrl });
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
fail(String(err && err.message || err));
|
|
3012
|
+
}
|
|
3013
|
+
};
|
|
3014
|
+
img.onerror = () => {
|
|
3015
|
+
URL.revokeObjectURL(url);
|
|
3016
|
+
fail(T.ensBadImage || "cannot read that image");
|
|
3017
|
+
};
|
|
3018
|
+
img.src = url;
|
|
3019
|
+
};
|
|
2870
3020
|
const walletRow = (lbl, chain, bound, onBind, last) => /* @__PURE__ */ React.createElement("div", { style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, lbl), bound ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, bound), /* @__PURE__ */ React.createElement(CopyBtn, { value: bound, copiedText: T.copied, copyFailedText: T.copyFailed, copyTitle: T.copy }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => bind(chain, "") }, T.ensUnbind || "unbind")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotBound || "not bound"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: onBind }, T.ensBind || "bind")));
|
|
2871
3021
|
return /* @__PURE__ */ React.createElement(Card, { label: T.ensCard || "Name \xB7 beagles.eth" }, st.loading ? /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), /* @__PURE__ */ React.createElement(
|
|
2872
3022
|
"input",
|
|
@@ -2881,12 +3031,15 @@ function DkEnsCard({ T, me }) {
|
|
|
2881
3031
|
},
|
|
2882
3032
|
style: field
|
|
2883
3033
|
}
|
|
2884
|
-
), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)));
|
|
3034
|
+
), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), upAvatar ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 34, height: 34, style: { width: 34, height: 34, borderRadius: 9, objectFit: "cover", flexShrink: 0, background: "var(--panel-2)" } }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--text)" } }, T.ensCustom || "custom image"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => fileRef.current && fileRef.current.click() }, T.ensUpload || "upload"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => setPicking(true) }, T.ensChoose || "choose"), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onUploadFile, style: { display: "none" } })), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) }));
|
|
2885
3035
|
}
|
|
2886
3036
|
function ProfileTab({ T, me, onEdit }) {
|
|
2887
3037
|
const [qr, setQr] = React.useState(null);
|
|
2888
3038
|
const [editing, setEditing] = React.useState(false);
|
|
2889
|
-
|
|
3039
|
+
const [ensRec, setEnsRec] = React.useState(null);
|
|
3040
|
+
const punkId = ensRec && ensRec.nft === "CryptoPunks" && ensRec.nftid > 0 ? ensRec.nftid : null;
|
|
3041
|
+
const upAvatar = ensRec && ensRec.texts && ensRec.texts.avatar || null;
|
|
3042
|
+
return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 18, padding: "20px 22px", borderRadius: 14, background: "var(--panel)", border: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, upAvatar ? /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 68, height: 68, style: { width: 68, height: 68, borderRadius: 16, objectFit: "cover", background: "var(--panel-2)" } }) : punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 68, radius: 16, fallbackSeed: me.userId }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: me.userId, size: 68, radius: 16 }), /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", right: -3, bottom: -3, width: 18, height: 18, borderRadius: 999, background: "var(--online)", border: "3px solid var(--panel)" } })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, letterSpacing: -0.5, color: "var(--text)" } }, me.name), /* @__PURE__ */ React.createElement(Tag, { tone: "ok" }, "online"), me.isExit && /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "exit", me.exitRegion ? ` \xB7 ${me.exitRegion.toUpperCase()}` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13, color: "var(--dim)", marginTop: 4 } }, me.handle), me.description ? /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 3 } }, me.description) : null), /* @__PURE__ */ React.createElement(Btn, { icon: "edit", onClick: () => setEditing(true) }, T.editProfile)), /* @__PURE__ */ React.createElement(Card, { label: T.identity }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.userId, value: me.userId, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.carrierAddr, value: me.carrier, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.netKey, value: me.netKey, copy: true, last: true })), /* @__PURE__ */ React.createElement(DkEnsCard, { T, me, onRecord: setEnsRec }), typeof me.autoAccept === "boolean" && /* @__PURE__ */ React.createElement(Card, { label: T && T.friendsSettings || "Friends" }, /* @__PURE__ */ React.createElement("label", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px", cursor: "pointer" } }, /* @__PURE__ */ React.createElement(
|
|
2890
3043
|
"input",
|
|
2891
3044
|
{
|
|
2892
3045
|
type: "checkbox",
|
|
@@ -2904,9 +3057,12 @@ function ProfileTab({ T, me, onEdit }) {
|
|
|
2904
3057
|
setEditing(false);
|
|
2905
3058
|
} }));
|
|
2906
3059
|
}
|
|
2907
|
-
Object.assign(window, { ProfileTab });
|
|
3060
|
+
Object.assign(window, { ProfileTab, DkPunkAvatar });
|
|
2908
3061
|
function DkDirAvatar({ p, size }) {
|
|
2909
3062
|
const [broken, setBroken] = React.useState(false);
|
|
3063
|
+
if (p.punkId != null && !(p.avatar && !broken)) {
|
|
3064
|
+
return /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: p.punkId, size, radius: Math.round(size / 4), fallbackSeed: p.userid || p.name });
|
|
3065
|
+
}
|
|
2910
3066
|
if (p.avatar && !broken) {
|
|
2911
3067
|
return /* @__PURE__ */ React.createElement(
|
|
2912
3068
|
"img",
|
|
@@ -3926,6 +4082,22 @@ const STR = {
|
|
|
3926
4082
|
ensBind: "bind",
|
|
3927
4083
|
ensUnbind: "unbind",
|
|
3928
4084
|
ensNotBound: "not bound",
|
|
4085
|
+
ensAvatar: "avatar",
|
|
4086
|
+
ensChoose: "choose",
|
|
4087
|
+
ensClear: "clear",
|
|
4088
|
+
ensNotSet: "not set",
|
|
4089
|
+
ensUpload: "upload",
|
|
4090
|
+
ensCustom: "custom image",
|
|
4091
|
+
ensTooBig: "image too large even after scaling",
|
|
4092
|
+
ensBadImage: "cannot read that image",
|
|
4093
|
+
ensPickPunk: "choose a punk",
|
|
4094
|
+
ensShuffle: "shuffle",
|
|
4095
|
+
pt_any: "any",
|
|
4096
|
+
pt_female: "female",
|
|
4097
|
+
pt_male: "male",
|
|
4098
|
+
pt_zombie: "zombie",
|
|
4099
|
+
pt_ape: "ape",
|
|
4100
|
+
pt_alien: "alien",
|
|
3929
4101
|
ensNoEthWallet: "no Ethereum wallet extension found (MetaMask\u2026)",
|
|
3930
4102
|
ensNoSolWallet: "no Solana wallet extension found (Phantom\u2026)",
|
|
3931
4103
|
ensWalletOwned: "registered via your mobile wallet as",
|
|
@@ -4051,6 +4223,22 @@ const STR = {
|
|
|
4051
4223
|
ensBind: "\u7ED1\u5B9A",
|
|
4052
4224
|
ensUnbind: "\u89E3\u7ED1",
|
|
4053
4225
|
ensNotBound: "\u672A\u7ED1\u5B9A",
|
|
4226
|
+
ensAvatar: "\u5934\u50CF",
|
|
4227
|
+
ensChoose: "\u9009\u62E9",
|
|
4228
|
+
ensClear: "\u6E05\u9664",
|
|
4229
|
+
ensNotSet: "\u672A\u8BBE\u7F6E",
|
|
4230
|
+
ensUpload: "\u4E0A\u4F20",
|
|
4231
|
+
ensCustom: "\u81EA\u5B9A\u4E49\u56FE\u7247",
|
|
4232
|
+
ensTooBig: "\u56FE\u7247\u538B\u7F29\u540E\u4ECD\u8D85\u8FC7 100KB",
|
|
4233
|
+
ensBadImage: "\u65E0\u6CD5\u8BFB\u53D6\u8BE5\u56FE\u7247",
|
|
4234
|
+
ensPickPunk: "\u9009\u4E00\u4E2A punk",
|
|
4235
|
+
ensShuffle: "\u6362\u4E00\u6279",
|
|
4236
|
+
pt_any: "\u5168\u90E8",
|
|
4237
|
+
pt_female: "\u5973",
|
|
4238
|
+
pt_male: "\u7537",
|
|
4239
|
+
pt_zombie: "\u50F5\u5C38",
|
|
4240
|
+
pt_ape: "\u733F",
|
|
4241
|
+
pt_alien: "\u5916\u661F\u4EBA",
|
|
4054
4242
|
ensNoEthWallet: "\u672A\u68C0\u6D4B\u5230\u4EE5\u592A\u574A\u94B1\u5305\u63D2\u4EF6(MetaMask \u7B49)",
|
|
4055
4243
|
ensNoSolWallet: "\u672A\u68C0\u6D4B\u5230 Solana \u94B1\u5305\u63D2\u4EF6(Phantom \u7B49)",
|
|
4056
4244
|
ensWalletOwned: "\u5DF2\u901A\u8FC7\u624B\u673A\u94B1\u5305\u6CE8\u518C\u4E3A",
|
package/dist/server.js
CHANGED
|
@@ -102,6 +102,9 @@ async function ensSignAndSet(call, record) {
|
|
|
102
102
|
if (!r.ok || !d?.success) {
|
|
103
103
|
return { ok: false, error: typeof d?.error === "string" ? d.error : `gateway HTTP ${r.status}` };
|
|
104
104
|
}
|
|
105
|
+
// The record changed — drop the cached /names listing so the Names tab
|
|
106
|
+
// reflects it immediately instead of after the 5-min TTL.
|
|
107
|
+
discoverCache.delete(`${ENS_GATEWAY}/names`);
|
|
105
108
|
return { ok: true };
|
|
106
109
|
}
|
|
107
110
|
catch (e) {
|
|
@@ -111,6 +114,46 @@ async function ensSignAndSet(call, record) {
|
|
|
111
114
|
clearTimeout(timer);
|
|
112
115
|
}
|
|
113
116
|
}
|
|
117
|
+
// ── CryptoPunks avatar directory ────────────────────────────────────────────
|
|
118
|
+
// Self-hosted punks API serving all 10k punks as inline SVG data URIs. The
|
|
119
|
+
// record convention (shared with the mobile apps) is nft="CryptoPunks" +
|
|
120
|
+
// nftid=<punk id> on the beagles.eth name. Proxied server-side (browser can't
|
|
121
|
+
// reach the origin's port cross-origin reliably); NOTE the upstream's TLS cert
|
|
122
|
+
// must be valid — renew it when these routes start failing.
|
|
123
|
+
const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://app.beagle.chat:1337";
|
|
124
|
+
const punkCache = new Map(); // punk id → JSON; immutable set
|
|
125
|
+
async function punksFetch(path) {
|
|
126
|
+
const ctl = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => ctl.abort(), 10_000);
|
|
128
|
+
try {
|
|
129
|
+
const r = await fetch(`${PUNKS_API_URL}${path}`, { signal: ctl.signal });
|
|
130
|
+
if (!r.ok)
|
|
131
|
+
throw new Error(`punks upstream ${r.status}`);
|
|
132
|
+
return await r.json();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Best-effort removal of the R2 avatar object when the record stops
|
|
139
|
+
* referencing it (punk picked / avatar cleared). Failure is harmless —
|
|
140
|
+
* an orphaned object just sits unused. */
|
|
141
|
+
async function ensAvatarDelete(call, fullName) {
|
|
142
|
+
try {
|
|
143
|
+
const name = fullName.toLowerCase();
|
|
144
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
145
|
+
const signed = await call({ op: "sign", text: `beagle-avatar\ndelete\n${name}\n${ts}` });
|
|
146
|
+
if (!signed.ok || !signed.data?.sig)
|
|
147
|
+
return;
|
|
148
|
+
await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
|
|
149
|
+
method: "DELETE",
|
|
150
|
+
headers: { "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// best-effort only
|
|
155
|
+
}
|
|
156
|
+
}
|
|
114
157
|
/** True only when the request came from the local machine. Used to gate the
|
|
115
158
|
* "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
|
|
116
159
|
* identity signing to other hosts. The popup always runs in the local user's
|
|
@@ -428,12 +471,146 @@ export function startBeagleServer(opts) {
|
|
|
428
471
|
// Register/update this node's *.beagles.eth name and bind wallet
|
|
429
472
|
// addresses into the record. The write routes trigger identity
|
|
430
473
|
// signatures, so they're LOCALHOST-ONLY like /connect.
|
|
431
|
-
if (url === "/api/ens-register" || url === "/api/ens-bind-wallet") {
|
|
474
|
+
if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar" || url === "/api/ens-avatar-upload") {
|
|
432
475
|
if (!isLocalRequest(req)) {
|
|
433
476
|
sendJson(res, 403, { ok: false, error: "identity signing is available only on this machine (localhost)" });
|
|
434
477
|
return;
|
|
435
478
|
}
|
|
436
479
|
}
|
|
480
|
+
// Punk directory (proxied): a random batch by type, and one punk by id.
|
|
481
|
+
if (req.method === "GET" && url.startsWith("/api/punk-list")) {
|
|
482
|
+
try {
|
|
483
|
+
const q = new URL(url, "http://localhost");
|
|
484
|
+
const type = (q.searchParams.get("type") || "any").toLowerCase().replace(/[^a-z]/g, "") || "any";
|
|
485
|
+
const limit = Math.min(60, Math.max(1, Number(q.searchParams.get("limit")) || 24));
|
|
486
|
+
const list = await punksFetch(`/api/punks/filter/${type}/any?limit=${limit}`);
|
|
487
|
+
sendJson(res, 200, { ok: true, list });
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
sendJson(res, 502, { ok: false, error: String(err?.message ?? err) });
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (req.method === "GET" && /^\/api\/punk\/\d{1,5}$/.test(url)) {
|
|
495
|
+
const id = url.slice("/api/punk/".length);
|
|
496
|
+
try {
|
|
497
|
+
let punk = punkCache.get(id);
|
|
498
|
+
if (!punk) {
|
|
499
|
+
punk = await punksFetch(`/api/punks/${id}`);
|
|
500
|
+
punkCache.set(id, punk);
|
|
501
|
+
}
|
|
502
|
+
sendJson(res, 200, { ok: true, punk });
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
sendJson(res, 502, { ok: false, error: String(err?.message ?? err) });
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
// Set (or clear) my punk avatar on the beagles.eth record.
|
|
510
|
+
if (req.method === "POST" && url === "/api/ens-avatar") {
|
|
511
|
+
const body = await readBody(req);
|
|
512
|
+
const clear = body.nftid === null || body.nftid === undefined;
|
|
513
|
+
const nftid = Number(body.nftid);
|
|
514
|
+
if (!clear && (!Number.isInteger(nftid) || nftid < 0 || nftid > 9999)) {
|
|
515
|
+
sendJson(res, 400, { ok: false, error: "invalid punk id" });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const diag = await opts.call({ op: "diag" });
|
|
519
|
+
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
520
|
+
if (!identity.userid) {
|
|
521
|
+
sendJson(res, 502, { ok: false, error: "identity unavailable" });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
|
|
525
|
+
if (!mine) {
|
|
526
|
+
sendJson(res, 404, { ok: false, error: "register a name first" });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (mine.owner !== identity.userid) {
|
|
530
|
+
sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — pick the avatar there" });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// One avatar at a time: picking a punk (or clearing) also drops any
|
|
534
|
+
// uploaded custom avatar from the record.
|
|
535
|
+
const texts = { ...(mine.texts ?? {}) };
|
|
536
|
+
const hadUpload = !!texts.avatar;
|
|
537
|
+
delete texts.avatar;
|
|
538
|
+
const r = await ensSignAndSet(opts.call, {
|
|
539
|
+
name: mine.name,
|
|
540
|
+
owner: identity.userid,
|
|
541
|
+
addresses: mine.addresses ?? {},
|
|
542
|
+
texts,
|
|
543
|
+
referee: mine.referee ?? "",
|
|
544
|
+
nft: clear ? "" : "CryptoPunks",
|
|
545
|
+
nftid: clear ? 0 : nftid,
|
|
546
|
+
});
|
|
547
|
+
if (r.ok && hadUpload)
|
|
548
|
+
void ensAvatarDelete(opts.call, mine.name); // best-effort R2 cleanup
|
|
549
|
+
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, nftid: clear ? null : nftid } : { ok: false, error: r.error });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Upload a custom avatar image: the browser sends a pre-scaled data URL,
|
|
553
|
+
// we push the bytes to the gateway's R2 (auth = identity signature) and
|
|
554
|
+
// point texts.avatar at it.
|
|
555
|
+
if (req.method === "POST" && url === "/api/ens-avatar-upload") {
|
|
556
|
+
const body = await readBody(req);
|
|
557
|
+
const m = /^data:(image\/(?:webp|png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(String(body.dataUrl ?? ""));
|
|
558
|
+
if (!m) {
|
|
559
|
+
sendJson(res, 400, { ok: false, error: "expected a webp/png/jpeg data URL" });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const bytes = Buffer.from(m[2], "base64");
|
|
563
|
+
if (bytes.length === 0 || bytes.length > 100 * 1024) {
|
|
564
|
+
sendJson(res, 413, { ok: false, error: "image must be ≤100 KB after scaling" });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const diag = await opts.call({ op: "diag" });
|
|
568
|
+
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
569
|
+
if (!identity.userid) {
|
|
570
|
+
sendJson(res, 502, { ok: false, error: "identity unavailable" });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
|
|
574
|
+
if (!mine) {
|
|
575
|
+
sendJson(res, 404, { ok: false, error: "register a name first" });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (mine.owner !== identity.userid) {
|
|
579
|
+
sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — set the avatar there" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const name = mine.name.toLowerCase();
|
|
583
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
584
|
+
const signed = await opts.call({ op: "sign", text: `beagle-avatar\nupload\n${name}\n${ts}` });
|
|
585
|
+
if (!signed.ok || !signed.data?.sig) {
|
|
586
|
+
sendJson(res, 502, { ok: false, error: "identity sign failed" });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const up = await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: { "content-type": m[1], "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
|
|
592
|
+
body: bytes,
|
|
593
|
+
});
|
|
594
|
+
const upBody = (await up.json().catch(() => null));
|
|
595
|
+
if (!up.ok || !upBody?.success) {
|
|
596
|
+
sendJson(res, 502, { ok: false, error: upBody?.error || `avatar upload HTTP ${up.status}` });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Point the record at it (?v= busts caches on re-upload) and drop any
|
|
600
|
+
// punk pick — one avatar at a time.
|
|
601
|
+
const avatarUrl = `${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}?v=${ts}`;
|
|
602
|
+
const r = await ensSignAndSet(opts.call, {
|
|
603
|
+
name: mine.name,
|
|
604
|
+
owner: identity.userid,
|
|
605
|
+
addresses: mine.addresses ?? {},
|
|
606
|
+
texts: { ...(mine.texts ?? {}), avatar: avatarUrl },
|
|
607
|
+
referee: mine.referee ?? "",
|
|
608
|
+
nft: "",
|
|
609
|
+
nftid: 0,
|
|
610
|
+
});
|
|
611
|
+
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, avatar: avatarUrl } : { ok: false, error: r.error });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
437
614
|
if (req.method === "GET" && url === "/api/ens-name") {
|
|
438
615
|
const diag = await opts.call({ op: "diag" });
|
|
439
616
|
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
@@ -835,7 +1012,7 @@ export function startBeagleServer(opts) {
|
|
|
835
1012
|
}
|
|
836
1013
|
if (req.method === "GET" && url === "/api/discover-registered") {
|
|
837
1014
|
try {
|
|
838
|
-
const raw = (await fetchDiscoverJson(
|
|
1015
|
+
const raw = (await fetchDiscoverJson(`${ENS_GATEWAY}/names`));
|
|
839
1016
|
const list = Object.entries(raw && typeof raw === "object" ? raw : {})
|
|
840
1017
|
.map(([ens, v]) => {
|
|
841
1018
|
const t = v?.texts ?? {};
|
|
@@ -845,6 +1022,10 @@ export function startBeagleServer(opts) {
|
|
|
845
1022
|
name: t.displayName || ens,
|
|
846
1023
|
ens,
|
|
847
1024
|
description: t.description || null,
|
|
1025
|
+
// custom uploaded avatar (R2 URL in texts) beats the punk pick
|
|
1026
|
+
avatar: typeof t.avatar === "string" && /^https:\/\//.test(t.avatar) ? t.avatar : null,
|
|
1027
|
+
// punk avatar convention: nft="CryptoPunks" + nftid>0 (0 = unset)
|
|
1028
|
+
punkId: v?.nft === "CryptoPunks" && typeof v?.nftid === "number" && v.nftid > 0 ? v.nftid : null,
|
|
848
1029
|
};
|
|
849
1030
|
})
|
|
850
1031
|
.filter((e) => e.userid && e.address);
|
package/package.json
CHANGED