From 0bdae85d24cd94f5854d5fb2fec5bfa3e9f9eed8 Mon Sep 17 00:00:00 2001 From: Ari Date: Mon, 22 Jun 2026 16:22:02 +0100 Subject: [PATCH] you'll never guess what command I forgot to run. --- js/discord.js | 838 +++++++++++++++++++++++++++++++++++++++++++++ socials/index.html | 114 ++++++ 2 files changed, 952 insertions(+) create mode 100644 js/discord.js create mode 100644 socials/index.html diff --git a/js/discord.js b/js/discord.js new file mode 100644 index 0000000..7b40891 --- /dev/null +++ b/js/discord.js @@ -0,0 +1,838 @@ +// nabbed from https://git.gay/doughmination/c.stupid.cat/src/branch/main/js/discord.js# with given permission uwu nya owo nyaaaa// + + +(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 : ""); + // Discord cards default to gg sans (Discord's own font) instead of the page's + // Comic Code. Per-name display fonts (set on .pc-name below) still override this. + card.style.fontFamily = "'DDN gg sans', sans-serif"; + 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.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({}); +})(); \ No newline at end of file diff --git a/socials/index.html b/socials/index.html new file mode 100644 index 0000000..d34e62e --- /dev/null +++ b/socials/index.html @@ -0,0 +1,114 @@ + + + + + + + + + + Ari + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ × + Index + Dev Info + Hardware + Curriculum Vitae + Socials + +
+ + +
+ +
+
+

My Socials

+
+

(She/They)

+
+ A list of all of my socials +
+
+
+ +
+
+ + + + + + + \ No newline at end of file