(function () { "use strict"; // ---- who are we showing? ------------------------------------------------ function valid(id) { return typeof id === "string" && /^\d{5,25}$/.test(id); } function resolveUserId() { const path = location.pathname.match(/\/api\/(\d{5,25})(?:[\/?#]|$)/); if (path) return path[1]; const qs = new URLSearchParams(location.search); const q = qs.get("u") || qs.get("id") || qs.get("user"); if (valid(q)) return q; if (/^#\d{5,25}$/.test(location.hash)) return location.hash.slice(1); const script = document.currentScript || document.querySelector("script[data-user]"); if (script && script.dataset && valid(script.dataset.user)) return script.dataset.user; const m = document.getElementById("discord"); if (m && m.dataset && valid(m.dataset.user)) return m.dataset.user; if (valid(window.DISCORD_USER_ID)) return window.DISCORD_USER_ID; return null; } // Build one presence card. Used full-size on /discord, and as small clones // on /cool-people (opts.mini). Options: // userId Discord id to show (defaults to resolveUserId()) // mount element to replace with the card (defaults to #discord) // mini true → adds .is-mini for smaller styling // tier / link friend tier class + optional website link on the name // pollMs presence refresh cadence (default 20s) // fallbackName / fallbackImg // shown immediately, and kept if the API has no data for them // (lets ID-less / dead alts still render a card) function createPresenceCard(opts) { opts = opts || {}; const DISCORD_USER_ID = opts.userId || resolveUserId(); const mount = opts.mount || document.getElementById("discord"); if (!mount) return null; if (!DISCORD_USER_ID && !opts.fallbackName) return null; // ---- theme: only on standalone api pages (homepage uses data-flavor) ---- if (!document.documentElement.getAttribute("data-flavor")) { const t = new URLSearchParams(location.search).get("theme"); const themes = ["mocha", "macchiato", "frappe", "latte"]; if (!document.documentElement.getAttribute("data-theme")) { document.documentElement.setAttribute("data-theme", themes.indexOf(t) >= 0 ? t : "mocha"); } } // ---- build the card ----------------------------------------------------- const card = document.createElement("div"); // Only the single owner card claims id="discord" (core.js + the gold-cat // observer key off it). Mini friend cards must not duplicate the id. if (!opts.mini) card.id = "discord"; card.className = "presence-card" + (opts.mini ? " is-mini" : "") + (opts.tier ? " tier-" + opts.tier : ""); card.hidden = true; card.innerHTML = '' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '' + '' + '
' + '
'; mount.replaceWith(card); const avImg = card.querySelector(".pc-av-img"); const avDeco = card.querySelector(".pc-av-deco"); const nameEl = card.querySelector(".pc-name"); const tagEl = card.querySelector(".pc-tag"); const userEl = card.querySelector(".pc-user"); const platformsEl = card.querySelector(".pc-platforms"); const statusTextEl = card.querySelector(".pc-status-text"); const STATUS_TITLE = { online: "Online", idle: "Idle", dnd: "Do Not Disturb", offline: "Offline" }; const metaEl = card.querySelector(".pc-meta"); const badgesEl = card.querySelector(".pc-badges"); const sections = card.querySelector(".pc-sections"); const idEl = card.querySelector(".pc-id"); const starBtn = card.querySelector(".pc-star"); const wishlistEl = card.querySelector(".pc-wishlist"); const bannerEl = card.querySelector(".pc-banner"); const bioEl = card.querySelector(".pc-bio"); const connectionsEl = card.querySelector(".pc-connections"); const pronounsEl = card.querySelector(".pc-pronouns"); // ---- friend-card extras: name link + instant placeholder ---------------- // Optional website link on the name (friends can have a personal site). if (opts.link) { nameEl.classList.add("pc-name--link"); nameEl.style.cursor = "pointer"; nameEl.setAttribute("role", "link"); nameEl.setAttribute("tabindex", "0"); const goLink = function () { window.open(opts.link, "_blank", "noopener"); }; nameEl.addEventListener("click", goLink); nameEl.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goLink(); } }); } // Seed a placeholder so the card shows instantly (and remains for ID-less or // offline dead alts the API can't fill). render() overwrites it on success. if (opts.fallbackName) { nameEl.textContent = opts.fallbackName; avImg.src = opts.fallbackImg || avatarUrl({ id: DISCORD_USER_ID }); card.dataset.status = "offline"; card.hidden = false; } // ---- wishlist (revealed by the star) ------------------------------------ // Items come straight from the Doughmination Restful API (j.data.wishlist): // each is a resolved Shop item { sku_id, type, name, static_image_url, // animated_image_url, video_url, label, is_owned, price, visibility }. let wishlistItems = null; const WL_TYPE_LABEL = { avatar_decoration: "Decoration", profile_effect: "Effect", nameplate: "Nameplate", bundle: "Bundle", variants_group: "Variants", external_sku: "Item" }; const CURRENCY_SYMBOL = { gbp: "£", usd: "$", eur: "€", aud: "A$", cad: "C$" }; function fmtPrice(p) { if (!p || typeof p.amount !== "number") return null; const exp = typeof p.exponent === "number" ? p.exponent : 2; const v = (p.amount / Math.pow(10, exp)).toFixed(exp); const sym = CURRENCY_SYMBOL[(p.currency || "").toLowerCase()]; return sym ? sym + v : v + " " + String(p.currency || "").toUpperCase(); } // Pick a thumbnail and decide whether the wsrv webp proxy is safe: avatar // decorations and profile effects are animated APNGs the proxy mangles (the // same reason the avatar decoration loads raw), so those go straight to the // CDN; nameplates and the rest are static and proxy fine. function wlImg(w) { const url = w.static_image_url || w.animated_image_url; if (!url) return null; if (w.type === "avatar_decoration" || w.type === "profile_effect" || /avatar-decoration-presets/.test(url)) { return url; } return proxyImg(url, { w: 64 }) || url; } function renderWishlist() { if (!wishlistEl) return; const items = Array.isArray(wishlistItems) ? wishlistItems : []; let body; if (items.length) { body = items.map(function (w) { const ic = wlImg(w); const typeLabel = WL_TYPE_LABEL[w.type] || ""; const price = fmtPrice(w.price); return '' + (ic ? '' : "") + '' + '' + esc(w.name || "Collectible") + "" + (typeLabel ? '' + esc(typeLabel) + "" : "") + "" + (price ? '' + esc(price) + "" : "") + ""; }).join(""); } else { body = '

nothing on the wishlist yet ✨

'; } wishlistEl.innerHTML = '
Wishlist
' + body; } if (starBtn) { starBtn.addEventListener("click", function (e) { e.stopPropagation(); const open = card.classList.toggle("show-wishlist"); starBtn.classList.toggle("on", open); starBtn.setAttribute("aria-expanded", open ? "true" : "false"); if (open) renderWishlist(); }); } let latest = null; let customNode = null; let ticker = null; let ws = null; let heartbeat = null; let reconnectDelay = 1000; // ---- small helpers ------------------------------------------------------ function fmt(ms) { const total = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${String(s).padStart(2, "0")}`; } function elapsedStr(start) { const s = Math.max(0, Math.floor((Date.now() - start) / 1000)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; } function clamp(n, lo, hi) { return Math.min(Math.max(n, lo), hi); } // pages.gay is a static host with no server-side compute, so we can't run // our own proxy. wsrv.nl is a free, cookieless image CDN: it re-serves the // image with no Set-Cookie (killing the third-party __cf_bm cookie) and can // convert to WebP on the fly. `opts` lets callers request a resize. function proxyImg(url, opts) { if (!url) return url; if (!/^https:\/\/(cdn|media)\.discordapp\.(com|net)\//.test(url)) return url; const src = url.replace(/^https:\/\//, ""); let 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) return proxyImg("https://cdn.discordapp.com/embed/avatars/0.png"); const ext = String(u.avatar).startsWith("a_") ? "gif" : "png"; return proxyImg(`https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}?size=128`, { w: 80 }); } function emojiUrl(e) { if (!e || !e.id) return null; return proxyImg(`https://cdn.discordapp.com/emojis/${e.id}.${e.animated ? "gif" : "png"}?size=32`); } function assetUrl(appId, asset) { if (!asset) return null; if (String(asset).startsWith("mp:")) return proxyImg("https://media.discordapp.net/" + asset.slice(3)); return proxyImg(`https://cdn.discordapp.com/app-assets/${appId}/${asset}.png`); } 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 guildBadgeUrl(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`); } const PLATFORM_ICONS = { desktop: '', mobile: '', web: '' }; function platformIcons(d) { let html = ""; if (d.active_on_discord_desktop) html += '' + PLATFORM_ICONS.desktop + ""; if (d.active_on_discord_mobile) html += '' + PLATFORM_ICONS.mobile + ""; if (d.active_on_discord_web || d.active_on_discord_embedded) html += '' + PLATFORM_ICONS.web + ""; return html; } const BADGE_FLAGS = [ [1 << 0, "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"], [1 << 1, "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], [1 << 2, "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"], [1 << 3, "Bug Hunter", "2717692c7dca7289b35297368a940dd0"], [1 << 6, "HypeSquad Bravery", "8a88d63823d8a71cd5e390baa45efa02"], [1 << 7, "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"], [1 << 8, "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], [1 << 9, "Early Supporter", "7060786766c9c840eb3019e725d2b358"], [1 << 14, "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"], [1 << 17, "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], [1 << 18, "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"], [1 << 22, "Active Developer", "6bdc42827a38498929a4920da12695d9"] ]; function renderBadges(flags) { flags = Number(flags) || 0; let html = ""; for (const [bit, name, hash] of BADGE_FLAGS) { if (flags & bit) { html += '' + esc(name) + ''; } } return html; } // Richer badges via dstn.to — Nitro, boosts, quests, orbs… everything // Discord actually shows, which public_flags (0 for most) can't give. let dstnBadges = null; let lastFlags = 0; function renderDstnBadges() { return dstnBadges.map(function (b) { const img = '' + esc(b.description || b.id) + ''; return b.link ? '' + img + "" : img; }).join(""); } function paintBadges() { if (!badgesEl) return; badgesEl.innerHTML = (dstnBadges && dstnBadges.length) ? renderDstnBadges() : renderBadges(lastFlags); } function rgbTriplet(n) { n = Number(n) >>> 0; return ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255); } function applyProfileGradient(colors) { if (!colors || colors.length < 2) return; card.style.setProperty("--pc-grad-1-rgb", rgbTriplet(colors[0])); card.style.setProperty("--pc-grad-2-rgb", rgbTriplet(colors[1])); card.classList.add("has-profile-grad"); } // ---- banner / bio / connected accounts (extras for the /discord page) --- 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). const animated = String(hash).startsWith("a_"); const url = "https://cdn.discordapp.com/banners/" + id + "/" + hash + (animated ? "" : ".png") + "?size=600"; return proxyImg(url, { w: 600 }); } function applyBanner(url, fallbackColor) { if (!bannerEl) return; if (url) { bannerEl.src = url; bannerEl.hidden = false; bannerEl.onerror = function () { bannerEl.hidden = true; card.classList.remove("has-banner"); }; card.classList.add("has-banner"); } else if (fallbackColor) { bannerEl.hidden = true; card.style.setProperty("--pc-banner-color", fallbackColor); card.classList.add("has-banner-color"); } } function renderBio(text) { if (!bioEl) return; if (text && String(text).trim()) { bioEl.textContent = String(text).trim(); bioEl.hidden = false; } else { bioEl.hidden = true; } } // Best-effort profile links for the common connection types. const CONNECTION_URLS = { tiktok: function (n) { return "https://tiktok.com/@" + n; }, ebay: function (n) { return "https://www.ebay.com/usr/" + n; }, instagram: function (n) {return "https://www.instagram.com/" + n; }, xbox: function (n) {return "https://www.xbox.com/en-GB/play/user/" + n; }, github: function (n) { return "https://github.com/" + n; }, roblox: function (n, id) { return "https://www.roblox.com/users/" + id + "/profile";}, epicgames: function (n, id) {return "https://store.epicgames.com/u/" + id; }, twitter: function (n) { return "https://twitter.com/" + n; }, twitch: function (n) { return "https://twitch.tv/" + n; }, youtube: function (n, id) { return "https://youtube.com/channel/" + id; }, spotify: function (n, id) { return "https://open.spotify.com/user/" + id; }, steam: function (n, id) { return "https://steamcommunity.com/profiles/" + id; }, reddit: function (n) { return "https://reddit.com/user/" + n; }, instagram: function (n) { return "https://instagram.com/" + n; }, domain: function (n) { return "https://" + n; }, bluesky: function (n) { return "https://bsky.app/profile/" + n; } }; // connection type -> brand SVG in /assets/socials (anything unmapped uses // the generic globe "site.svg") const CONNECTION_ICON = { "amazon-music": "amazon", facebook: "facebook", ebay: "ebay", tiktok: "tiktok", bungie: "bungie", // playstation: "playstation", paypal: "paypal", instagram: "instagram", xbox: "xbox", crunchyroll: "crunchyroll", battlenet: "battlenet", github: "github", epicgames: "epic", riotgames: "riot", leagueoflegends: "league", steam: "steam", roblox: "roblox", twitter: "twitter", bluesky: "bluesky", mastodon: "mastodon", twitch: "twitch", youtube: "youtube", reddit: "reddit", spotify: "spotify", discord: "discord", linkedin: "linkedin", domain: "site" }; function connIcon(type) { const file = CONNECTION_ICON[String(type || "").toLowerCase()] || "site"; return '' +
      esc(type) + ''; } function renderConnections(accounts) { if (!connectionsEl) return; const list = (accounts || []).filter(function (a) { return a && a.name; }); if (!list.length) { connectionsEl.hidden = true; return; } connectionsEl.innerHTML = list.map(function (a) { const maker = CONNECTION_URLS[a.type]; const url = maker ? maker(a.name, a.id) : null; const inner = connIcon(a.type) + '' + esc(a.name) + "" + (a.verified ? '' : ""); return url ? '' + inner + "" : '' + inner + ""; }).join(""); connectionsEl.hidden = false; } // ---- album-art → Catppuccin accent -------------------------------------- const ACCENT_VARS = [ "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "saphire", "blue", "lavender", ]; function hexToRgb(hex) { hex = hex.trim().replace("#", ""); if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); const n = parseInt(hex, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function getThemePalette() { const cs = getComputedStyle(document.documentElement); const pal = []; for (const name of ACCENT_VARS) { const v = cs.getPropertyValue("--" + name).trim(); if (v.startsWith("#")) { const [r, g, b] = hexToRgb(v); pal.push({ r, g, b }); } } return pal; } function nearestAccent(r, g, b) { const pal = getThemePalette(); let best = null, bestD = Infinity; for (const c of pal) { const rm = (r + c.r) / 2, dr = r - c.r, dg = g - c.g, db = b - c.b; const d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db; if (d < bestD) { bestD = d; best = c; } } return best; } let lastArtUrl = null; function applyAccent(url) { if (!url || url === lastArtUrl) return; lastArtUrl = url; const img = new Image(); img.crossOrigin = "anonymous"; img.referrerPolicy = "no-referrer"; img.onload = () => { try { const c = document.createElement("canvas"); c.width = c.height = 16; const ctx = c.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0, 16, 16); const { data } = ctx.getImageData(0, 0, 16, 16); let r = 0, g = 0, b = 0, count = 0; for (let i = 0; i < data.length; i += 4) { if (data[i + 3] < 125) continue; const lum = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]; if (lum < 24 || lum > 235) continue; r += data[i]; g += data[i + 1]; b += data[i + 2]; count++; } if (!count) { resetAccent(); return; } r = Math.round(r / count); g = Math.round(g / count); b = Math.round(b / count); const near = nearestAccent(r, g, b); const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`; card.style.setProperty("--dc-accent", rgb); card.classList.add("has-accent"); // Only the full-size card recolours the whole page; mini friend cards // keep their accent local so they don't fight over --accent-rgb. if (!opts.mini) document.documentElement.style.setProperty("--accent-rgb", rgb); } catch (e) { resetAccent(); } }; img.onerror = resetAccent; img.src = url; } function resetAccent() { lastArtUrl = null; card.classList.remove("has-accent"); card.style.removeProperty("--dc-accent"); if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb"); } // ---- section (row) builders -------------------------------------------- function rowText(kind, title, sub, extra) { return ( '' + '' + esc(kind) + "" + '' + esc(title) + "" + '' + esc(sub) + "" + (extra || "") + "" ); } function customRow(a) { const row = document.createElement("div"); row.className = "pc-row pc-custom"; const eu = emojiUrl(a.emoji); row.innerHTML = (eu ? '' : '') + '' + esc(a.state || "") + ""; return row; } function spotifyRow(s) { const row = document.createElement("a"); row.className = "pc-row pc-spotify"; row.target = "_blank"; row.rel = "noopener"; row.href = s.track_id ? "https://open.spotify.com/track/" + s.track_id : "https://open.spotify.com/"; if (s.album) row.title = (s.song || "") + " — " + s.album; if (s.timestamps && s.timestamps.start) row.dataset.start = s.timestamps.start; if (s.timestamps && s.timestamps.end) row.dataset.end = s.timestamps.end; row.innerHTML = (s.album_art_url ? '' : "") + rowText("Listening to Spotify", s.song || "", s.artist || "", '"); return row; } // Generic activity row (type 0). Discord presence exposes no link for // games or apps, so this renders as a non-clickable card. function activityRow(a) { const isCode = /visual studio code|vscode/i.test(a.name || ""); const row = document.createElement("div"); row.className = "pc-row pc-row--stack " + (isCode ? "pc-dev" : "pc-game"); if (a.timestamps && a.timestamps.start) row.dataset.elapsedStart = a.timestamps.start; const large = a.assets && a.assets.large_image && assetUrl(a.application_id, a.assets.large_image); const small = a.assets && a.assets.small_image && assetUrl(a.application_id, a.assets.small_image); const iconHtml = large ? '' + '' + (small ? '' : "") + "" : ''; let kind = isCode ? "Coding" : "Playing " + (a.name || ""); if (a.party && a.party.size && a.party.size.length === 2 && a.party.size[1]) { kind += " · " + a.party.size[0] + " of " + a.party.size[1]; } const main = document.createElement("div"); main.className = "pc-row-link"; main.innerHTML = iconHtml + rowText(kind, a.details || (isCode ? "" : a.name) || "", a.state || (a.assets && a.assets.large_text) || "", ''); row.appendChild(main); // Discord only exposes button *labels* (not URLs) via presence, so these // are shown as plain (non-clickable) chips. if (a.buttons && a.buttons.length) { const bwrap = document.createElement("div"); bwrap.className = "pc-buttons"; a.buttons.forEach(function (label) { const b = document.createElement("span"); b.className = "pc-btn"; b.textContent = typeof label === "string" ? label : (label && label.label) || "Open"; bwrap.appendChild(b); }); row.appendChild(bwrap); } return row; } function streamRow(a) { const hasUrl = !!a.url; const row = document.createElement(hasUrl ? "a" : "div"); row.className = "pc-row pc-stream"; if (hasUrl) { row.target = "_blank"; row.rel = "noopener"; row.href = a.url; } const platform = (a.url && /twitch/i.test(a.url)) ? "Twitch" : (a.url && /youtube/i.test(a.url)) ? "YouTube" : "Live"; row.innerHTML = '' + rowText("Streaming on " + platform, a.details || a.name || "", a.state || ""); return row; } // Discord display-name fonts (display_name_styles.font_id) -> our @font-face // families in css/fonts.css. Only ids we have a look-alike for are mapped; // any other id falls back to the card's normal font. (Comments = Discord's // underlying font; "verify" = best-guess pairing with your file names.) const NAME_FONTS = { 3: "'DDN Sakura', cursive", // 3 CHERRY_BOMB 4: "'DDN Jellybean', cursive", // 4 CHICLE 6: "'DDN Modern', sans-serif", // 6 MUSEO_MODERNO 7: "'DDN Medieval', serif", // 7 NEO_CASTEL 8: "'DDN 8Bit', monospace", // 8 PIXELIFY 10: "'DDN Vampyre', serif", // 10 SINISTRE 11: "'DDN gg sans', sans-serif", // 11 DEFAULT (Discord's normal font) 12: "'DDN Tempo', serif", // 12 ZILLA_SLAB }; // ---- render ------------------------------------------------------------- function render(d) { if (!d) return; latest = d; const u = d.discord_user || {}; const status = d.discord_status || "offline"; card.dataset.status = status; if (statusTextEl) statusTextEl.textContent = STATUS_TITLE[status] || "Offline"; avImg.src = avatarUrl(u); const deco = u.avatar_decoration_data; if (deco && deco.asset) { // Load decorations straight from Discord's CDN: they're animated APNGs, // and the wsrv webp proxy fails on them (and would drop the animation). avDeco.src = `https://cdn.discordapp.com/avatar-decoration-presets/${deco.asset}.png`; avDeco.hidden = false; } else { avDeco.hidden = true; } nameEl.textContent = u.display_name || u.global_name || u.username || "Discord User"; userEl.textContent = u.username ? "@" + u.username : ""; const styles = u.display_name_styles; if (styles && styles.colors && styles.colors.length) { const cols = styles.colors.map(intToHex); nameEl.style.backgroundImage = "linear-gradient(90deg, " + (cols.length === 1 ? cols[0] + "," + cols[0] : cols.join(", ")) + ")"; nameEl.classList.add("is-gradient"); } else { nameEl.style.backgroundImage = ""; nameEl.classList.remove("is-gradient"); } // Custom display-name font from Discord's font_id (falls back to the // card's normal font when there's no style or no look-alike for that id). nameEl.style.fontFamily = (styles && NAME_FONTS[styles.font_id]) || ""; const pg = u.primary_guild; if (pg && pg.tag && pg.identity_enabled) { const badge = guildBadgeUrl(pg); tagEl.innerHTML = (badge ? '' : "") + '' + esc(pg.tag) + ""; tagEl.hidden = false; } else { tagEl.hidden = true; } platformsEl.innerHTML = platformIcons(d); lastFlags = u.public_flags || 0; paintBadges(); const loc = d.kv && d.kv.location; if (loc) { metaEl.innerHTML = '' + esc(loc); metaEl.hidden = false; } else { metaEl.hidden = true; } const acts = d.activities || []; sections.innerHTML = ""; // The custom status renders in the identity column, directly under the // name, so its thought-bubble tail rises to the username (Discord-style) // — rather than down in the activity list. if (customNode) { customNode.remove(); customNode = null; } const custom = acts.find((a) => a.type === 4); if (custom && (custom.state || (custom.emoji && custom.emoji.id))) { customNode = customRow(custom); idEl.appendChild(customNode); } card.classList.toggle("has-custom", !!customNode); if (d.listening_to_spotify && d.spotify) { sections.appendChild(spotifyRow(d.spotify)); applyAccent(d.spotify.album_art_url); } else { resetAccent(); } acts.filter((a) => a.type === 0).forEach((a) => sections.appendChild(activityRow(a))); acts.filter((a) => a.type === 1).forEach((a) => sections.appendChild(streamRow(a))); card.classList.toggle("has-sections", sections.children.length > 0); updateTimes(); if (sections.querySelector("[data-start], [data-elapsed-start]")) startTicker(); else stopTicker(); card.hidden = false; } // ---- time tickers (progress bar + elapsed labels) ----------------------- function updateTimes() { const sp = sections.querySelector(".pc-spotify[data-start][data-end]"); if (sp) { const start = +sp.dataset.start, end = +sp.dataset.end; if (end > start) { const elapsed = clamp(Date.now() - start, 0, end - start); const fill = sp.querySelector(".pc-fill"); const cur = sp.querySelector(".pc-cur"); const dur = sp.querySelector(".pc-dur"); if (fill) fill.style.width = clamp((elapsed / (end - start)) * 100, 0, 100) + "%"; if (cur) cur.textContent = fmt(elapsed); if (dur) dur.textContent = fmt(end - start); } } sections.querySelectorAll("[data-elapsed-start]").forEach((row) => { const lbl = row.querySelector(".pc-row-elapsed"); if (lbl) lbl.textContent = elapsedStr(+row.dataset.elapsedStart); }); } function startTicker() { if (!ticker) ticker = setInterval(updateTimes, 1000); } function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } } // ---- data source: Doughmination Restful API (sole source) --------------- // Returns presence + full profile (incl. theme_colors + display_name_styles) // in a single call. Lanyard + dstn.to were removed. const SELF_BASE = "https://restful.doughmination.uk/v1/users/"; const SELF_POLL_MS = opts.pollMs || 20000; // presence refresh cadence let selfTimer = null; // self-host shape -> the Lanyard-shaped object render() already understands function mapSelfHostToLanyard(j) { const u = (j.data && j.data.user) || {}; const p = (j.data && j.data.presence) || {}; const plat = p.platform || {}; const dec = u.avatar_decoration; const clan = u.clan; return { discord_user: { id: u.id || DISCORD_USER_ID, username: u.username, global_name: u.global_name, display_name: u.display_name, avatar: u.avatar, avatar_decoration_data: (dec && dec.asset) ? { asset: dec.asset } : null, primary_guild: (clan && clan.tag) ? { tag: clan.tag, identity_enabled: true, badge: clan.badge, identity_guild_id: clan.guild_id } : null, // carry the Nitro name styling through so render() can apply the // gradient + custom font (font_id) — without this it never reaches it display_name_styles: u.display_name_styles || null, public_flags: u.public_flags || 0 }, discord_status: p.status || (p.online ? "online" : "offline"), activities: p.activities || [], listening_to_spotify: !!p.listening_to_spotify, spotify: p.spotify || null, active_on_discord_desktop: !!plat.desktop, active_on_discord_mobile: !!plat.mobile, active_on_discord_web: !!plat.web, kv: {} }; } function renderFromSelfHost(j) { const u = (j.data && j.data.user) || {}; render(mapSelfHostToLanyard(j)); // badges arrive pre-resolved (same consumer as dstn badges) if (Array.isArray(j.data.badges) && j.data.badges.length) { dstnBadges = j.data.badges; paintBadges(); } // Nitro profile gradient — straight from the self-hosted API now that it // returns theme_colors (previously only the dstn.to fallback applied this) if (Array.isArray(u.theme_colors)) applyProfileGradient(u.theme_colors); // profile extras: banner rebuilt from the raw hash (dodges the animated // .gif 415), plus bio / connections / pronouns straight from the API applyBanner( bannerUrl(u.id || DISCORD_USER_ID, u.banner), (typeof u.accent_color === "number") ? intToHex(u.accent_color) : null ); renderBio(u.bio); renderConnections(j.data.connected_accounts); if (pronounsEl) { if (u.pronouns) { pronounsEl.textContent = u.pronouns; pronounsEl.hidden = false; } else pronounsEl.hidden = true; } // wishlist: resolved Shop collectibles (null when the API couldn't load it). // Keep the panel live if it's already open when fresh data arrives. wishlistItems = Array.isArray(j.data.wishlist) ? j.data.wishlist : null; if (card.classList.contains("show-wishlist")) renderWishlist(); } function loadSelfHosted() { return fetch(SELF_BASE + DISCORD_USER_ID, { cache: "no-store" }) .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; }) .then(function (j) { // render whenever the API has the user; presence may be null (offline) if (!j || !j.success || !j.data || !j.data.user) return false; renderFromSelfHost(j); return true; }) .catch(function () { return false; }); } function pollSelfHost() { if (!document.hidden) loadSelfHosted(); } // boot: poll the Doughmination Restful API (the only source now). ID-less // placeholder cards (e.g. dead alts) keep their seeded look — no fetch. if (DISCORD_USER_ID) { loadSelfHosted(); selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); } document.addEventListener("visibilitychange", () => { if (!document.hidden && latest) updateTimes(); }); return card; } // ---- end createPresenceCard ---- // Expose the factory so other pages (e.g. /cool-people) can build cards. window.PresenceCard = createPresenceCard; // Auto-mount the standalone card whenever its #discord placeholder exists, // preserving the original /discord page behaviour exactly. if (document.getElementById("discord")) createPresenceCard({}); })();