@decentnetwork/beagle 0.1.21 → 0.1.23
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 +113 -5
- package/dist/server.js +148 -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.22";
|
|
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"/>',
|
|
@@ -2956,17 +2956,110 @@ function DkEnsCard({ T, me, onRecord }) {
|
|
|
2956
2956
|
};
|
|
2957
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" };
|
|
2958
2958
|
const row = { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--line)" };
|
|
2959
|
+
const rowLbl = { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 };
|
|
2960
|
+
const SOCIALS = [
|
|
2961
|
+
{ k: "twitter", base: "https://x.com/" },
|
|
2962
|
+
{ k: "linkedin", base: "https://www.linkedin.com/in/" },
|
|
2963
|
+
{ k: "github", base: "https://github.com/" }
|
|
2964
|
+
];
|
|
2965
|
+
const [draft, setDraft] = React.useState({});
|
|
2966
|
+
React.useEffect(() => {
|
|
2967
|
+
const t = st.record && st.record.texts || {};
|
|
2968
|
+
setDraft({ twitter: t["com.twitter"] || "", linkedin: t["com.linkedin"] || "", github: t["com.github"] || "" });
|
|
2969
|
+
}, [st.record]);
|
|
2970
|
+
const saveSocial = (k) => post("/api/ens-social", { key: k, value: (draft[k] || "").trim() });
|
|
2971
|
+
const socialRow = ({ k, base }, last) => {
|
|
2972
|
+
var _a;
|
|
2973
|
+
const saved = (st.record && st.record.texts || {})["com." + k] || "";
|
|
2974
|
+
const cur = (_a = draft[k]) != null ? _a : "";
|
|
2975
|
+
return /* @__PURE__ */ React.createElement("div", { key: k, style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { style: rowLbl }, k), /* @__PURE__ */ React.createElement(
|
|
2976
|
+
"input",
|
|
2977
|
+
{
|
|
2978
|
+
value: cur,
|
|
2979
|
+
onChange: (e) => setDraft({ ...draft, [k]: e.target.value }),
|
|
2980
|
+
disabled: busy || !st.mineOwned,
|
|
2981
|
+
placeholder: T.linkPlaceholder || "username or profile URL",
|
|
2982
|
+
onKeyDown: (e) => {
|
|
2983
|
+
if (e.key === "Enter")
|
|
2984
|
+
saveSocial(k);
|
|
2985
|
+
},
|
|
2986
|
+
style: field
|
|
2987
|
+
}
|
|
2988
|
+
), saved && /* @__PURE__ */ React.createElement(
|
|
2989
|
+
"a",
|
|
2990
|
+
{
|
|
2991
|
+
href: base + saved,
|
|
2992
|
+
target: "_blank",
|
|
2993
|
+
rel: "noreferrer",
|
|
2994
|
+
title: base + saved,
|
|
2995
|
+
style: { flexShrink: 0, fontFamily: "var(--mono)", fontSize: 13, color: "var(--accent)", textDecoration: "none" }
|
|
2996
|
+
},
|
|
2997
|
+
"\u2197"
|
|
2998
|
+
), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !st.mineOwned || cur.trim() === saved, onClick: () => saveSocial(k) }, saved && !cur.trim() ? T.ensUnbind || "unbind" : T.save || "save"));
|
|
2999
|
+
};
|
|
2959
3000
|
const [picking, setPicking] = React.useState(false);
|
|
3001
|
+
const fileRef = React.useRef(null);
|
|
2960
3002
|
const rec = st.record;
|
|
2961
3003
|
const boundEth = rec && rec.addresses && rec.addresses["60"];
|
|
2962
3004
|
const boundSol = rec && rec.addresses && rec.addresses["501"];
|
|
2963
3005
|
const punkId = rec && rec.nft === "CryptoPunks" && rec.nftid > 0 ? rec.nftid : null;
|
|
3006
|
+
const upAvatar = rec && rec.texts && rec.texts.avatar || null;
|
|
2964
3007
|
const pickPunk = (id) => {
|
|
2965
3008
|
setPicking(false);
|
|
2966
3009
|
post("/api/ens-avatar", { nftid: id });
|
|
2967
3010
|
};
|
|
3011
|
+
const onUploadFile = (e) => {
|
|
3012
|
+
const f = e.target.files && e.target.files[0];
|
|
3013
|
+
e.target.value = "";
|
|
3014
|
+
if (!f)
|
|
3015
|
+
return;
|
|
3016
|
+
setBusy(true);
|
|
3017
|
+
setMsg(null);
|
|
3018
|
+
const img = new Image();
|
|
3019
|
+
const url = URL.createObjectURL(f);
|
|
3020
|
+
const fail = (text) => {
|
|
3021
|
+
setBusy(false);
|
|
3022
|
+
setMsg({ tone: "err", text });
|
|
3023
|
+
};
|
|
3024
|
+
img.onload = () => {
|
|
3025
|
+
URL.revokeObjectURL(url);
|
|
3026
|
+
try {
|
|
3027
|
+
const S = 256;
|
|
3028
|
+
const c = document.createElement("canvas");
|
|
3029
|
+
c.width = S;
|
|
3030
|
+
c.height = S;
|
|
3031
|
+
const ctx = c.getContext("2d");
|
|
3032
|
+
const side = Math.min(img.width, img.height);
|
|
3033
|
+
ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S);
|
|
3034
|
+
const enc = (q) => {
|
|
3035
|
+
let d = c.toDataURL("image/webp", q);
|
|
3036
|
+
if (!d.startsWith("data:image/webp"))
|
|
3037
|
+
d = c.toDataURL("image/jpeg", q);
|
|
3038
|
+
return d;
|
|
3039
|
+
};
|
|
3040
|
+
let dataUrl = null;
|
|
3041
|
+
for (const q of [0.9, 0.75, 0.55, 0.4]) {
|
|
3042
|
+
dataUrl = enc(q);
|
|
3043
|
+
if (dataUrl.length < 13e4)
|
|
3044
|
+
break;
|
|
3045
|
+
}
|
|
3046
|
+
if (!dataUrl || dataUrl.length >= 13e4) {
|
|
3047
|
+
fail(T.ensTooBig || "image too large even after scaling");
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
post("/api/ens-avatar-upload", { dataUrl });
|
|
3051
|
+
} catch (err) {
|
|
3052
|
+
fail(String(err && err.message || err));
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
img.onerror = () => {
|
|
3056
|
+
URL.revokeObjectURL(url);
|
|
3057
|
+
fail(T.ensBadImage || "cannot read that image");
|
|
3058
|
+
};
|
|
3059
|
+
img.src = url;
|
|
3060
|
+
};
|
|
2968
3061
|
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")));
|
|
2969
|
-
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(
|
|
3062
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__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(
|
|
2970
3063
|
"input",
|
|
2971
3064
|
{
|
|
2972
3065
|
value: label,
|
|
@@ -2979,14 +3072,15 @@ function DkEnsCard({ T, me, onRecord }) {
|
|
|
2979
3072
|
},
|
|
2980
3073
|
style: field
|
|
2981
3074
|
}
|
|
2982
|
-
), /* @__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"), 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: () => setPicking(true) }, T.ensChoose || "choose")), 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) }));
|
|
3075
|
+
), /* @__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) })), !st.loading && st.registered && /* @__PURE__ */ React.createElement(Card, { label: T.linksCard || "Links" }, SOCIALS.map((s, i) => socialRow(s, i === SOCIALS.length - 1))));
|
|
2983
3076
|
}
|
|
2984
3077
|
function ProfileTab({ T, me, onEdit }) {
|
|
2985
3078
|
const [qr, setQr] = React.useState(null);
|
|
2986
3079
|
const [editing, setEditing] = React.useState(false);
|
|
2987
3080
|
const [ensRec, setEnsRec] = React.useState(null);
|
|
2988
3081
|
const punkId = ensRec && ensRec.nft === "CryptoPunks" && ensRec.nftid > 0 ? ensRec.nftid : null;
|
|
2989
|
-
|
|
3082
|
+
const upAvatar = ensRec && ensRec.texts && ensRec.texts.avatar || null;
|
|
3083
|
+
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(
|
|
2990
3084
|
"input",
|
|
2991
3085
|
{
|
|
2992
3086
|
type: "checkbox",
|
|
@@ -3007,7 +3101,7 @@ function ProfileTab({ T, me, onEdit }) {
|
|
|
3007
3101
|
Object.assign(window, { ProfileTab, DkPunkAvatar });
|
|
3008
3102
|
function DkDirAvatar({ p, size }) {
|
|
3009
3103
|
const [broken, setBroken] = React.useState(false);
|
|
3010
|
-
if (p.punkId != null) {
|
|
3104
|
+
if (p.punkId != null && !(p.avatar && !broken)) {
|
|
3011
3105
|
return /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: p.punkId, size, radius: Math.round(size / 4), fallbackSeed: p.userid || p.name });
|
|
3012
3106
|
}
|
|
3013
3107
|
if (p.avatar && !broken) {
|
|
@@ -4033,6 +4127,10 @@ const STR = {
|
|
|
4033
4127
|
ensChoose: "choose",
|
|
4034
4128
|
ensClear: "clear",
|
|
4035
4129
|
ensNotSet: "not set",
|
|
4130
|
+
ensUpload: "upload",
|
|
4131
|
+
ensCustom: "custom image",
|
|
4132
|
+
ensTooBig: "image too large even after scaling",
|
|
4133
|
+
ensBadImage: "cannot read that image",
|
|
4036
4134
|
ensPickPunk: "choose a punk",
|
|
4037
4135
|
ensShuffle: "shuffle",
|
|
4038
4136
|
pt_any: "any",
|
|
@@ -4041,6 +4139,9 @@ const STR = {
|
|
|
4041
4139
|
pt_zombie: "zombie",
|
|
4042
4140
|
pt_ape: "ape",
|
|
4043
4141
|
pt_alien: "alien",
|
|
4142
|
+
linksCard: "Links",
|
|
4143
|
+
linkPlaceholder: "username or profile URL",
|
|
4144
|
+
save: "save",
|
|
4044
4145
|
ensNoEthWallet: "no Ethereum wallet extension found (MetaMask\u2026)",
|
|
4045
4146
|
ensNoSolWallet: "no Solana wallet extension found (Phantom\u2026)",
|
|
4046
4147
|
ensWalletOwned: "registered via your mobile wallet as",
|
|
@@ -4170,6 +4271,10 @@ const STR = {
|
|
|
4170
4271
|
ensChoose: "\u9009\u62E9",
|
|
4171
4272
|
ensClear: "\u6E05\u9664",
|
|
4172
4273
|
ensNotSet: "\u672A\u8BBE\u7F6E",
|
|
4274
|
+
ensUpload: "\u4E0A\u4F20",
|
|
4275
|
+
ensCustom: "\u81EA\u5B9A\u4E49\u56FE\u7247",
|
|
4276
|
+
ensTooBig: "\u56FE\u7247\u538B\u7F29\u540E\u4ECD\u8D85\u8FC7 100KB",
|
|
4277
|
+
ensBadImage: "\u65E0\u6CD5\u8BFB\u53D6\u8BE5\u56FE\u7247",
|
|
4173
4278
|
ensPickPunk: "\u9009\u4E00\u4E2A punk",
|
|
4174
4279
|
ensShuffle: "\u6362\u4E00\u6279",
|
|
4175
4280
|
pt_any: "\u5168\u90E8",
|
|
@@ -4178,6 +4283,9 @@ const STR = {
|
|
|
4178
4283
|
pt_zombie: "\u50F5\u5C38",
|
|
4179
4284
|
pt_ape: "\u733F",
|
|
4180
4285
|
pt_alien: "\u5916\u661F\u4EBA",
|
|
4286
|
+
linksCard: "\u793E\u4EA4\u8D26\u53F7",
|
|
4287
|
+
linkPlaceholder: "\u7528\u6237\u540D\u6216\u4E3B\u9875\u94FE\u63A5",
|
|
4288
|
+
save: "\u4FDD\u5B58",
|
|
4181
4289
|
ensNoEthWallet: "\u672A\u68C0\u6D4B\u5230\u4EE5\u592A\u574A\u94B1\u5305\u63D2\u4EF6(MetaMask \u7B49)",
|
|
4182
4290
|
ensNoSolWallet: "\u672A\u68C0\u6D4B\u5230 Solana \u94B1\u5305\u63D2\u4EF6(Phantom \u7B49)",
|
|
4183
4291
|
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) {
|
|
@@ -132,6 +135,25 @@ async function punksFetch(path) {
|
|
|
132
135
|
clearTimeout(timer);
|
|
133
136
|
}
|
|
134
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
|
+
}
|
|
135
157
|
/** True only when the request came from the local machine. Used to gate the
|
|
136
158
|
* "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
|
|
137
159
|
* identity signing to other hosts. The popup always runs in the local user's
|
|
@@ -449,7 +471,7 @@ export function startBeagleServer(opts) {
|
|
|
449
471
|
// Register/update this node's *.beagles.eth name and bind wallet
|
|
450
472
|
// addresses into the record. The write routes trigger identity
|
|
451
473
|
// signatures, so they're LOCALHOST-ONLY like /connect.
|
|
452
|
-
if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar") {
|
|
474
|
+
if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar" || url === "/api/ens-avatar-upload" || url === "/api/ens-social") {
|
|
453
475
|
if (!isLocalRequest(req)) {
|
|
454
476
|
sendJson(res, 403, { ok: false, error: "identity signing is available only on this machine (localhost)" });
|
|
455
477
|
return;
|
|
@@ -508,18 +530,140 @@ export function startBeagleServer(opts) {
|
|
|
508
530
|
sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — pick the avatar there" });
|
|
509
531
|
return;
|
|
510
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;
|
|
511
538
|
const r = await ensSignAndSet(opts.call, {
|
|
512
539
|
name: mine.name,
|
|
513
540
|
owner: identity.userid,
|
|
514
541
|
addresses: mine.addresses ?? {},
|
|
515
|
-
texts
|
|
542
|
+
texts,
|
|
516
543
|
referee: mine.referee ?? "",
|
|
517
544
|
nft: clear ? "" : "CryptoPunks",
|
|
518
545
|
nftid: clear ? 0 : nftid,
|
|
519
546
|
});
|
|
547
|
+
if (r.ok && hadUpload)
|
|
548
|
+
void ensAvatarDelete(opts.call, mine.name); // best-effort R2 cleanup
|
|
520
549
|
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, nftid: clear ? null : nftid } : { ok: false, error: r.error });
|
|
521
550
|
return;
|
|
522
551
|
}
|
|
552
|
+
// Bind/update/remove a social handle on the record, stored under the
|
|
553
|
+
// standard ENS text keys (com.twitter / com.linkedin / com.github) so
|
|
554
|
+
// other ENS-aware clients can read them too. Empty value = remove.
|
|
555
|
+
if (req.method === "POST" && url === "/api/ens-social") {
|
|
556
|
+
const SOCIAL_KEYS = { twitter: "com.twitter", linkedin: "com.linkedin", github: "com.github" };
|
|
557
|
+
const body = await readBody(req);
|
|
558
|
+
const key = SOCIAL_KEYS[String(body.key ?? "")];
|
|
559
|
+
if (!key) {
|
|
560
|
+
sendJson(res, 400, { ok: false, error: "key must be twitter, linkedin or github" });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Accept a bare handle, @handle, or a pasted profile URL — keep the handle.
|
|
564
|
+
const value = String(body.value ?? "")
|
|
565
|
+
.trim()
|
|
566
|
+
.replace(/^https?:\/\/(www\.)?(x\.com|twitter\.com|github\.com|linkedin\.com\/(in|company))\//i, "")
|
|
567
|
+
.replace(/\/+$/, "")
|
|
568
|
+
.replace(/^@/, "");
|
|
569
|
+
if (value && !/^[A-Za-z0-9_.-]{1,64}$/.test(value)) {
|
|
570
|
+
sendJson(res, 400, { ok: false, error: "handle may only contain letters, digits, _ . -" });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const diag = await opts.call({ op: "diag" });
|
|
574
|
+
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
575
|
+
if (!identity.userid) {
|
|
576
|
+
sendJson(res, 502, { ok: false, error: "identity unavailable" });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
|
|
580
|
+
if (!mine) {
|
|
581
|
+
sendJson(res, 404, { ok: false, error: "register a name first" });
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (mine.owner !== identity.userid) {
|
|
585
|
+
sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — edit links there" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const texts = { ...(mine.texts ?? {}) };
|
|
589
|
+
if (value)
|
|
590
|
+
texts[key] = value;
|
|
591
|
+
else
|
|
592
|
+
delete texts[key];
|
|
593
|
+
const r = await ensSignAndSet(opts.call, {
|
|
594
|
+
name: mine.name,
|
|
595
|
+
owner: identity.userid,
|
|
596
|
+
addresses: mine.addresses ?? {},
|
|
597
|
+
texts,
|
|
598
|
+
referee: mine.referee ?? "",
|
|
599
|
+
nft: mine.nft ?? "",
|
|
600
|
+
nftid: mine.nftid ?? 0,
|
|
601
|
+
});
|
|
602
|
+
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, key: body.key, value: value || null } : { ok: false, error: r.error });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// Upload a custom avatar image: the browser sends a pre-scaled data URL,
|
|
606
|
+
// we push the bytes to the gateway's R2 (auth = identity signature) and
|
|
607
|
+
// point texts.avatar at it.
|
|
608
|
+
if (req.method === "POST" && url === "/api/ens-avatar-upload") {
|
|
609
|
+
const body = await readBody(req);
|
|
610
|
+
const m = /^data:(image\/(?:webp|png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(String(body.dataUrl ?? ""));
|
|
611
|
+
if (!m) {
|
|
612
|
+
sendJson(res, 400, { ok: false, error: "expected a webp/png/jpeg data URL" });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const bytes = Buffer.from(m[2], "base64");
|
|
616
|
+
if (bytes.length === 0 || bytes.length > 100 * 1024) {
|
|
617
|
+
sendJson(res, 413, { ok: false, error: "image must be ≤100 KB after scaling" });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const diag = await opts.call({ op: "diag" });
|
|
621
|
+
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
622
|
+
if (!identity.userid) {
|
|
623
|
+
sendJson(res, 502, { ok: false, error: "identity unavailable" });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
|
|
627
|
+
if (!mine) {
|
|
628
|
+
sendJson(res, 404, { ok: false, error: "register a name first" });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (mine.owner !== identity.userid) {
|
|
632
|
+
sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — set the avatar there" });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const name = mine.name.toLowerCase();
|
|
636
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
637
|
+
const signed = await opts.call({ op: "sign", text: `beagle-avatar\nupload\n${name}\n${ts}` });
|
|
638
|
+
if (!signed.ok || !signed.data?.sig) {
|
|
639
|
+
sendJson(res, 502, { ok: false, error: "identity sign failed" });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const up = await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
|
|
643
|
+
method: "POST",
|
|
644
|
+
headers: { "content-type": m[1], "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
|
|
645
|
+
body: bytes,
|
|
646
|
+
});
|
|
647
|
+
const upBody = (await up.json().catch(() => null));
|
|
648
|
+
if (!up.ok || !upBody?.success) {
|
|
649
|
+
sendJson(res, 502, { ok: false, error: upBody?.error || `avatar upload HTTP ${up.status}` });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Point the record at it (?v= busts caches on re-upload) and drop any
|
|
653
|
+
// punk pick — one avatar at a time.
|
|
654
|
+
const avatarUrl = `${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}?v=${ts}`;
|
|
655
|
+
const r = await ensSignAndSet(opts.call, {
|
|
656
|
+
name: mine.name,
|
|
657
|
+
owner: identity.userid,
|
|
658
|
+
addresses: mine.addresses ?? {},
|
|
659
|
+
texts: { ...(mine.texts ?? {}), avatar: avatarUrl },
|
|
660
|
+
referee: mine.referee ?? "",
|
|
661
|
+
nft: "",
|
|
662
|
+
nftid: 0,
|
|
663
|
+
});
|
|
664
|
+
sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, avatar: avatarUrl } : { ok: false, error: r.error });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
523
667
|
if (req.method === "GET" && url === "/api/ens-name") {
|
|
524
668
|
const diag = await opts.call({ op: "diag" });
|
|
525
669
|
const identity = (diag.ok ? diag.data?.identity : null) ?? {};
|
|
@@ -931,6 +1075,8 @@ export function startBeagleServer(opts) {
|
|
|
931
1075
|
name: t.displayName || ens,
|
|
932
1076
|
ens,
|
|
933
1077
|
description: t.description || null,
|
|
1078
|
+
// custom uploaded avatar (R2 URL in texts) beats the punk pick
|
|
1079
|
+
avatar: typeof t.avatar === "string" && /^https:\/\//.test(t.avatar) ? t.avatar : null,
|
|
934
1080
|
// punk avatar convention: nft="CryptoPunks" + nftid>0 (0 = unset)
|
|
935
1081
|
punkId: v?.nft === "CryptoPunks" && typeof v?.nftid === "number" && v.nftid > 0 ? v.nftid : null,
|
|
936
1082
|
};
|
package/package.json
CHANGED