(function friends() { "use strict"; var FRIENDS = [ { title: "Fiancée", members: [ { name: "Aria", tier: "wife", discordId: "1305215902685597797", link: null } ] }, { title: "Close Friends", members: [ { name: "Lilly", tier: "close", discordId: "908055723659898902", link: null }, { name: "Ria", tier: "close", discordId: "1513506390088618145", link: null }, { name: "Camilla", tier: "close", discordId: "1110542429838397471", link: "https://cammy-the-cat.com" }, { name: "Saphie", tier: "close", discordId: "527709099186716673", link: null }, { name: "Ari", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" } ] }, { title: "Friends", members: [ { name: "Fin", tier: "friend", discordId: "867818211574808607", link: null }, { name: "Meme", tier: "friend", discordId: "812998699667161098", link: null }, { name: "N", tier: "friend", discordId: "639399972407869450", link: null }, { name: "Lylla", tier: "friend", discordId: "1009889543878611016", link: null }, { name: "Simon", tier: "friend", discordId: "758466783354814514", link: null } ] }, { title: "Other Peeps", subtitle: "You can request to be added here!", members: [ { name: "furi", tier: "known", discordId: "781445370177126401", link: "https://furina.is-a.dev"}, { name: "pokemon", tier: "known", discordId: "784443338627612673", link: "https://devmatei.com/" } ] }, { title: "Alts", subtitle: "My other accounts, dead or alive", members: [ { name: "J", img: "/assets/alts/j.png", tier: "active-alt", discordId: "1500197577336033301", link: null}, { name: "Uzi", img: "/assets/alts/uzi.png", tier: "active-alt", discordId: "526626867973849123", link: null }, { name: "clovetwilight3", img: "/assets/alts/clovetwilight3.png", tier: "dead-alt", discordId: null, link: null }, { name: "estrogenhrt", img: "/assets/alts/estrogenhrt.png", tier: "dead-alt", discordId: null, link: null }, { name: "Clove <3", img: "/assets/alts/clove.png", tier: "dead-alt", discordId: "1125844710511104030", link: null}, { name: "Clove ⛤", img: "/assets/alts/butterfly.png", tier: "dead-alt", discordId: "514994021970739201", link: null }, { name: "Mrow", img: "/assets/alts/mrow.png", tier: "dead-alt", discordId: "219480349053288450", link: null } ] } ]; var REFRESH_MS = 60000; // re-poll live friends once a minute var root = document.getElementById("friends-root"); if (!root) return; // title → URL-safe anchor id, e.g. "Close Friends" -> "close-friends" function slugify(str) { return String(str == null ? "" : str) .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } // ---- helpers (mirrors discord.js) ----------------------------------- function esc(str) { return String(str == null ? "" : str) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function intToHex(n) { return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6); } function rgbTriplet(n) { n = Number(n) >>> 0; return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255); } // Nitro profile gradient -> card background (mirrors the /discord card) function applyGrad(refs, colors) { if (!colors || colors.length < 2) return; refs.el.style.setProperty("--fc-grad-1-rgb", rgbTriplet(colors[0])); refs.el.style.setProperty("--fc-grad-2-rgb", rgbTriplet(colors[1])); refs.el.classList.add("has-profile-grad"); } // Nitro "display name styles" -> gradient on the name text (same effect the // /discord card uses). Lanyard + dstn.to expose this; our API does not yet. // Discord's 8 display-name fonts -> closest free Google Fonts (approximations; // Discord's own faces are proprietary). font_id 1 = gg sans = our default. var FONT_BY_ID = { 2: '"Poppins", sans-serif', // Tempo 3: '"Klee One", cursive', // Sakura 4: '"Baloo 2", cursive', // Jellybean 5: '"Montserrat", sans-serif', // Modern 6: '"MedievalSharp", cursive', // Medieval 7: '"Press Start 2P", monospace', // 8Bit 8: '"Pirata One", system-ui' // Vampyre }; var GFONTS_HREF = "https://fonts.googleapis.com/css2?" + "family=Baloo+2:wght@600&family=Klee+One:wght@600&family=MedievalSharp&" + "family=Montserrat:wght@700&family=Pirata+One&family=Poppins:wght@600&" + "family=Press+Start+2P&display=swap"; var _fontsInjected = false; function ensureFonts() { if (_fontsInjected) return; _fontsInjected = true; var l = document.createElement("link"); l.rel = "stylesheet"; l.href = GFONTS_HREF; document.head.appendChild(l); } function applyNameStyle(refs, styles) { if (!refs.name || !styles) return; // gradient / colour if (styles.colors && styles.colors.length) { var cols = styles.colors.map(intToHex); refs.name.style.backgroundImage = "linear-gradient(90deg, " + (cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")"; refs.name.classList.add("is-gradient"); } // font face (lazy-load the Google Fonts sheet only when one is used) var fam = styles.font_id && FONT_BY_ID[styles.font_id]; if (fam) { ensureFonts(); refs.name.style.fontFamily = fam; } } // Re-serve Discord CDN images cookieless via wsrv.nl (same trick as the // presence card) so no third-party cookies are set. function proxyImg(url, opts) { if (!url) return url; if (!/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(url)) return url; var src = url.replace(/^https:\/\//, ""); var q = "https://wsrv.nl/?url=" + encodeURIComponent(src) + "&output=webp"; if (opts && opts.w) q += "&w=" + opts.w + "&dpr=2&fit=cover"; return q; } function avatarUrl(u) { if (!u || !u.avatar) { var def = (Number(u && u.discriminator) || 0) % 5; return proxyImg("https://cdn.discordapp.com/embed/avatars/" + def + ".png"); } var ext = String(u.avatar).startsWith("a_") ? "gif" : "png"; return proxyImg("https://cdn.discordapp.com/avatars/" + u.id + "/" + u.avatar + "." + ext + "?size=128", { w: 96 }); } function bannerUrl(id, hash) { if (!id || !hash) return null; // Animated banners (a_) must be requested WITHOUT the .gif extension: // Discord's CDN throws HTTP 415 for some a_*.gif banners, but the // extension-less URL works (wsrv then re-serves it as cookieless webp). var animated = String(hash).startsWith("a_"); var url = "https://cdn.discordapp.com/banners/" + id + "/" + hash + (animated ? "" : ".png") + "?size=480"; return proxyImg(url, { w: 480 }); } // custom-status emoji → CDN url (custom emoji) or null (unicode emoji) function customEmojiUrl(e) { if (!e || !e.id) return null; return proxyImg("https://cdn.discordapp.com/emojis/" + e.id + (e.animated ? ".gif" : ".png") + "?size=32"); } // Render the Discord custom status (activity type 4): emoji + text. function renderCustomStatus(refs, activities) { if (!refs.custom) return; var c = (activities || []).find(function (a) { return a && a.type === 4; }); var text = c && c.state ? String(c.state) : ""; var emoji = c && c.emoji; if (!text && !(emoji && (emoji.id || emoji.name))) { refs.custom.hidden = true; refs.custom.innerHTML = ""; return; } var eu = emoji ? customEmojiUrl(emoji) : null; var emojiHtml = eu ? '' : (emoji && emoji.name ? '' + esc(emoji.name) + "" : ""); refs.custom.innerHTML = emojiHtml + (text ? '' + esc(text) + "" : ""); refs.custom.hidden = false; } // dstn.to badge list → small icon row (same source the presence card uses) function renderDstnBadges(badges) { if (!Array.isArray(badges)) return ""; return badges.map(function (b) { var img = '' + esc(b.description || b.id) + ''; return b.link ? '' + img + "" : img; }).join(""); } // Self-hosted badge list → small icon row. The self-hosted API ships a // ready-made `icon_url`; fall back to building it from `icon` if absent. function renderSelfBadges(badges) { if (!Array.isArray(badges)) return ""; return badges.map(function (b) { var url = b.icon_url || ("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png"); var img = '' + esc(b.description || b.id) + ''; return b.link ? '' + img + "" : img; }).join(""); } // Self-hosted clan tag → guild badge + tag. Shape: {tag, badge_url}. function renderSelfClan(refs, clan) { if (!refs.tag) return; if (clan && clan.tag) { var b = clan.badge_url ? proxyImg(clan.badge_url) : null; refs.tag.innerHTML = (b ? '' : "") + '' + esc(clan.tag) + ""; refs.tag.hidden = false; } else { refs.tag.hidden = true; } } // Discord server (clan) tag — the little guild badge + tag next to a name function guildTagBadgeUrl(pg) { if (!pg || !pg.badge || !pg.identity_guild_id) return null; return proxyImg("https://cdn.discordapp.com/guild-tag-badges/" + pg.identity_guild_id + "/" + pg.badge + ".png?size=24"); } function renderClanTag(refs, pg) { if (!refs.tag) return; if (pg && pg.tag && pg.identity_enabled) { var b = guildTagBadgeUrl(pg); refs.tag.innerHTML = (b ? '' : "") + '' + esc(pg.tag) + ""; refs.tag.hidden = false; } else { refs.tag.hidden = true; } } // ---- build one card ------------------------------------------------- function buildCard(m) { var card = document.createElement("article"); card.className = "friend-card" + (m.tier ? " tier-" + m.tier : ""); card.dataset.status = "unconnected"; // until proven otherwise var nameTag = m.link ? "a" : "span"; var nameAttrs = m.link ? ' href="' + esc(m.link) + '" target="_blank" rel="noopener"' : ""; card.innerHTML = '
' + '
' + '' + '' + esc(m.name) + '' + '' + '' + '' + '' + '' + '<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '' + '' + '' + '' + '' + '' + '' + '
'; // Hover expansion: pin the card's current height BEFORE adding // .is-hovering (which lifts .fc-main out of flow). Pinning keeps the // grid slot a fixed size so the page never reflows -> no hover ping-pong. card.addEventListener("mouseenter", function () { card.style.height = card.offsetHeight + "px"; card.classList.add("is-hovering"); }); card.addEventListener("mouseleave", function () { card.classList.remove("is-hovering"); card.style.height = ""; }); return { el: card, name: card.querySelector(".fc-name"), pfp: card.querySelector(".fc-pfp"), deco: card.querySelector(".fc-deco"), statusDot: card.querySelector(".fc-status"), user: card.querySelector(".fc-user"), custom: card.querySelector(".fc-custom"), tag: card.querySelector(".fc-tag"), badges: card.querySelector(".fc-badges"), banner: card.querySelector(".fc-banner") }; } var STATUS_TITLE = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", offline: "Offline" }; // ---- live data: self-hosted API (primary source) ------------------- // Returns a promise resolving to: // "full" → user + live presence rendered (no fallback needed) // "profile" → user rendered but presence was null (fall back to Lanyard // for the live status dot + current activity/custom status) // false → nothing usable (full Lanyard + dstn fallback) var SELF_BASE = "https://restful.doughmination.uk/v1/users/"; function loadSelfHosted(m, refs) { return fetch(SELF_BASE + m.discordId, { cache: "no-store" }) .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; }) .then(function (j) { if (!j || !j.success || !j.data || !j.data.user) return false; var u = j.data.user || {}; var p = j.data.presence; // may be null when the user isn't live-tracked var havePresence = !!p; // --- profile (rendered whenever we have a user object) --- if (u.avatar) { refs.pfp.src = avatarUrl({ id: u.id || m.discordId, avatar: u.avatar }); } // avatar decoration — load straight from Discord's CDN (the wsrv webp // proxy drops the APNG animation). API ships a ready `url`. if (refs.deco) { var deco = u.avatar_decoration; var dUrl = deco && (deco.url || (deco.asset ? "https://cdn.discordapp.com/avatar-decoration-presets/" + deco.asset + ".png" : null)); if (dUrl) { refs.deco.src = dUrl; refs.deco.hidden = false; } else { refs.deco.hidden = true; } } if (u.username) refs.user.textContent = "@" + u.username; renderSelfClan(refs, u.clan); if (Array.isArray(j.data.badges) && j.data.badges.length) { refs.badges.innerHTML = renderSelfBadges(j.data.badges); } // banner — rebuild from the raw hash via bannerUrl() rather than the // API's banner_url, which uses a `.gif` extension that 415s for some // animated banners. Falls back to accent colour. var bUrl = bannerUrl(u.id || m.discordId, u.banner); if (bUrl) { refs.banner.style.backgroundImage = "url('" + bUrl + "')"; refs.el.classList.add("has-banner"); } else if (typeof u.accent_color === "number") { refs.banner.style.background = intToHex(u.accent_color); refs.el.classList.add("has-banner"); } if (Array.isArray(u.theme_colors)) applyGrad(refs, u.theme_colors); applyNameStyle(refs, u.display_name_styles); // --- live presence --- if (havePresence) { var status = p.status || (p.online ? "online" : "offline"); refs.el.dataset.status = status; refs.statusDot.title = STATUS_TITLE[status] || "Offline"; // activities follow the standard Discord shape (type 4 = custom status) renderCustomStatus(refs, p.activities); return "full"; } // User EXISTS in our API but we have no live presence for them (they're // offline, or not in a guild our gateway tracks). Treat "exists" as // offline rather than the default blue "unconnected". Only accounts the // API can't find at all keep the blue dot. refs.el.dataset.status = "offline"; refs.statusDot.title = "Offline"; return "profile"; }) .catch(function () { return false; }); } // ---- resolve a member: Doughmination Restful API ONLY --------------- // Lanyard + dstn.to were removed. Everything (presence, profile, badges, // banner, Nitro gradient, display-name styles) now comes from // restful.doughmination.uk in a single call. Resolves true if the API had // the user, false if not found (caller stops polling after a few misses). function refreshMember(m, refs) { return loadSelfHosted(m, refs).then(function (result) { return result !== false; }); } // Stop polling a member after this many consecutive "not found anywhere" // results. Keeps 404s (e.g. someone not in the self-host DB or Lanyard) // from spamming the console on every refresh. Any successful resolve resets // the counter, so a transient blip won't permanently drop a friend. Tunable. var GIVE_UP_AFTER = 3; function pollEntry(entry) { if (entry.stop) return; refreshMember(entry.m, entry.refs).then(function (resolved) { if (resolved) { entry.misses = 0; return; } entry.misses++; if (entry.misses >= GIVE_UP_AFTER) entry.stop = true; // give up for this session }); } // ---- render --------------------------------------------------------- var liveMembers = []; // {m, refs} for those with an id, for polling FRIENDS.forEach(function (group) { var section = document.createElement("section"); section.className = "section"; section.id = slugify(group.title); // anchor target, e.g. #alts var h2 = document.createElement("h2"); h2.className = "section-title"; h2.textContent = group.title; section.appendChild(h2); if (group.subtitle) { var sub = document.createElement("p"); sub.className = "section-subtitle"; sub.textContent = group.subtitle; section.appendChild(sub); } var grid = document.createElement("div"); grid.className = "friend-grid"; group.members.forEach(function (m) { var refs = buildCard(m); grid.appendChild(refs.el); // poll anyone with an ID — even "dead" alts can be semi-alive. Accounts // that 404 everywhere are dropped automatically by the give-up logic. if (m.discordId) { var entry = { m: m, refs: refs, misses: 0, stop: false }; liveMembers.push(entry); pollEntry(entry); } }); section.appendChild(grid); root.appendChild(section); }); // ---- jump to anchor (sections are built after page load) ------------ function scrollToHash() { var id = (location.hash || "").slice(1); if (!id) return; var target = document.getElementById(id); if (target) target.scrollIntoView(); } scrollToHash(); window.addEventListener("hashchange", scrollToHash); // ---- poll live members ---------------------------------------------- if (liveMembers.length) { setInterval(function () { if (document.hidden) return; liveMembers.forEach(function (x) { if (!x.stop) pollEntry(x); }); }, REFRESH_MS); } })();