449 lines
19 KiB
JavaScript
449 lines
19 KiB
JavaScript
(function friends() {
|
|
"use strict";
|
|
|
|
var FRIENDS = [
|
|
{
|
|
title: "Fiancée",
|
|
members: [
|
|
{ name: "Aria", img: "/assets/friends/ari.png", tier: "wife", discordId: "1305215902685597797", link: null }
|
|
]
|
|
},
|
|
{
|
|
title: "Close Friends",
|
|
members: [
|
|
{ name: "Lilly", img: "/assets/friends/lilly.png", tier: "close", discordId: "908055723659898902", link: null },
|
|
{ name: "Ria", img: "/assets/friends/ria.png", tier: "close", discordId: "1513506390088618145", link: null },
|
|
{ name: "Camilla", img: "/assets/friends/camilla.png", tier: "close", discordId: "1110542429838397471", link: "https://cammy-the-cat.com" },
|
|
{ name: "Saphie", img: "/assets/friends/saphie.png", tier: "close", discordId: "527709099186716673", link: null },
|
|
{ name: "Ari", img: "/assets/friends/meowhem.png", tier: "close", discordId: "1474568910736199825", link: "https://a.stupid.cat" }
|
|
]
|
|
},
|
|
{
|
|
title: "Friends",
|
|
members: [
|
|
{ name: "Fin", img: "/assets/friends/fin.png", tier: "friend", discordId: "867818211574808607", link: null },
|
|
{ name: "Meme", img: "/assets/friends/meme.png", tier: "friend", discordId: "812998699667161098", link: null },
|
|
{ name: "N", img: "/assets/friends/n.png", tier: "friend", discordId: "639399972407869450", link: null },
|
|
{ name: "Lylla", img: "/assets/friends/lylla.png", tier: "friend", discordId: "1009889543878611016", link: null },
|
|
{ name: "Simon", img: "/assets/friends/simon.png", tier: "friend", discordId: "758466783354814514", link: null }
|
|
]
|
|
},
|
|
{
|
|
title: "Other Peeps",
|
|
subtitle: "You can request to be added here!",
|
|
members: [
|
|
{ name: "furi", img: "/assets/known-people/furi.png", tier: "known", discordId: "781445370177126401", link: "https://furina.is-a.dev"},
|
|
{ name: "pokemon", img: "/assets/known-people/pokemon.png", 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, ">").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
|
|
? '<img class="fc-custom-emoji" src="' + eu + '" alt="" onerror="this.remove()">'
|
|
: (emoji && emoji.name ? '<span class="fc-custom-emoji-uni">' + esc(emoji.name) + "</span>" : "");
|
|
refs.custom.innerHTML = emojiHtml + (text ? '<span class="fc-custom-text">' + esc(text) + "</span>" : "");
|
|
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 = '<img class="fc-badge" src="' +
|
|
proxyImg("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png") +
|
|
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
|
|
'" onerror="this.remove()">';
|
|
return b.link
|
|
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
|
|
: 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 = '<img class="fc-badge" src="' + proxyImg(url) +
|
|
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
|
|
'" onerror="this.remove()">';
|
|
return b.link
|
|
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
|
|
: 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 ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
|
|
'<span class="fc-tag-text">' + esc(clan.tag) + "</span>";
|
|
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 ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
|
|
'<span class="fc-tag-text">' + esc(pg.tag) + "</span>";
|
|
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 =
|
|
'<div class="fc-banner"></div>' +
|
|
'<div class="fc-main">' +
|
|
'<span class="fc-avatar">' +
|
|
'<img class="fc-pfp" src="' + esc(m.img) + '" alt="' + esc(m.name) + '" loading="eager">' +
|
|
'<img class="fc-deco" alt="" aria-hidden="true" hidden>' +
|
|
'<span class="fc-status" title="not connected to Lanyard"></span>' +
|
|
'</span>' +
|
|
'<span class="fc-id">' +
|
|
'<span class="fc-name-row">' +
|
|
'<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '</' + nameTag + '>' +
|
|
'<span class="fc-tag" hidden></span>' +
|
|
'</span>' +
|
|
'<span class="fc-user"></span>' +
|
|
'<span class="fc-custom" hidden></span>' +
|
|
'<span class="fc-badges"></span>' +
|
|
'</span>' +
|
|
'</div>';
|
|
|
|
// 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);
|
|
}
|
|
})();
|