314 lines
12 KiB
JavaScript
314 lines
12 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: "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: "Aureal", img: "/assets/friends/aureal.gif", tier: "known", discordId: "1498977251134279900", link: "https://aureal.dev/" }
|
|
]
|
|
},
|
|
{
|
|
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: "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 now-playing.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);
|
|
}
|
|
// 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("");
|
|
}
|
|
|
|
// 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="lazy">' +
|
|
'<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>';
|
|
|
|
return {
|
|
el: card,
|
|
pfp: card.querySelector(".fc-pfp"),
|
|
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: Lanyard (status + avatar) ---------------------------
|
|
function loadLanyard(m, refs) {
|
|
return fetch("https://api.lanyard.rest/v1/users/" + m.discordId)
|
|
.then(function (r) { return r.json().catch(function () { return null; }); })
|
|
.then(function (j) {
|
|
if (!j || !j.success || !j.data) {
|
|
// in config but not in the Lanyard server → stays "unconnected"
|
|
return false;
|
|
}
|
|
var d = j.data;
|
|
var u = d.discord_user || {};
|
|
var status = d.discord_status || "offline";
|
|
refs.el.dataset.status = status;
|
|
refs.statusDot.title = STATUS_TITLE[status] || "Offline";
|
|
if (u.avatar) refs.pfp.src = avatarUrl(u);
|
|
if (u.username) refs.user.textContent = "@" + u.username;
|
|
renderCustomStatus(refs, d.activities);
|
|
renderClanTag(refs, u.primary_guild);
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
// API down / network error → static fallback, blue dot
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// ---- live data: dstn.to (badges + Nitro banner) ---------------------
|
|
function loadDstn(m, refs) {
|
|
return fetch("https://dcdn.dstn.to/profile/" + m.discordId)
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (j) {
|
|
if (!j) return;
|
|
var u = j.user || {};
|
|
var prof = j.user_profile || {};
|
|
if (Array.isArray(j.badges) && j.badges.length) {
|
|
refs.badges.innerHTML = renderDstnBadges(j.badges);
|
|
}
|
|
var bUrl = bannerUrl(u.id || m.discordId, prof.banner || u.banner);
|
|
if (bUrl) {
|
|
refs.banner.style.backgroundImage = "url('" + bUrl + "')";
|
|
refs.el.classList.add("has-banner");
|
|
} else if (u.banner_color) {
|
|
refs.banner.style.background = u.banner_color;
|
|
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");
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function refreshMember(m, refs) {
|
|
loadLanyard(m, refs).then(function (live) {
|
|
if (live) loadDstn(m, refs);
|
|
});
|
|
}
|
|
|
|
// ---- 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);
|
|
// dead alts are banned/retired — never pull live data for them
|
|
if (m.discordId && m.tier !== "dead-alt") {
|
|
liveMembers.push({ m: m, refs: refs });
|
|
refreshMember(m, refs);
|
|
}
|
|
});
|
|
|
|
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) { refreshMember(x.m, x.refs); });
|
|
}, REFRESH_MS);
|
|
}
|
|
})();
|