diff --git a/discord/index.html b/discord/index.html
new file mode 100644
index 0000000..5681005
--- /dev/null
+++ b/discord/index.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+ Clove Twilight - Discord
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Discord
+
What fae is up to, live via Lanyard.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/friends.js b/js/friends.js
new file mode 100644
index 0000000..7c0a314
--- /dev/null
+++ b/js/friends.js
@@ -0,0 +1,248 @@
+(function friends() {
+ "use strict";
+
+ // =====================================================================
+ // FRIENDS CONFIG ✏️ EDIT ME
+ // ---------------------------------------------------------------------
+ // Each friend is one card. Fields:
+ // name (required) display name
+ // img (required) static fallback image (used until/unless Lanyard
+ // gives a live avatar, and forever if they're not
+ // in the Lanyard server or the API is down)
+ // discordId (optional) Discord user ID. If set AND they're in the
+ // Lanyard server (https://discord.gg/lanyard),
+ // the card goes live: real avatar, status dot
+ // (green/idle/dnd/offline), badges + Nitro banner.
+ // Leave null for friends who aren't in the server —
+ // they get a blue "not connected" dot + static img.
+ // link (optional) where the card links to (their site, etc.)
+ //
+ // To make Camilla (etc.) live, paste her Discord ID into discordId.
+ // IMPORTANT: keep IDs as STRINGS in quotes ("123..."), not bare numbers —
+ // Discord IDs are too big for a JS number and get rounded (wrong user!).
+ // =====================================================================
+ 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/" }
+ ]
+ }
+ ];
+
+ var REFRESH_MS = 60000; // re-poll live friends once a minute
+
+ var root = document.getElementById("friends-root");
+ if (!root) return;
+
+ // ---- helpers (mirrors now-playing.js) -------------------------------
+ function esc(str) {
+ return String(str == null ? "" : str)
+ .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+ function intToHex(n) {
+ return "#" + (Number(n) >>> 0).toString(16).padStart(6, "0").slice(-6);
+ }
+ // 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;
+ var ext = String(hash).startsWith("a_") ? "gif" : "png";
+ return proxyImg("https://cdn.discordapp.com/banners/" + id + "/" + hash + "." + ext + "?size=480", { w: 480 });
+ }
+
+ // 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 = '
';
+ return b.link
+ ? '' + img + ""
+ : img;
+ }).join("");
+ }
+
+ // ---- 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 =
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '' + nameTag + '>' +
+ '' +
+ '' +
+ '' +
+ '
';
+
+ return {
+ el: card,
+ pfp: card.querySelector(".fc-pfp"),
+ statusDot: card.querySelector(".fc-status"),
+ user: card.querySelector(".fc-user"),
+ 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;
+ 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";
+
+ 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);
+ if (m.discordId) {
+ liveMembers.push({ m: m, refs: refs });
+ refreshMember(m, refs);
+ }
+ });
+
+ section.appendChild(grid);
+ root.appendChild(section);
+ });
+
+ // ---- poll live members ----------------------------------------------
+ if (liveMembers.length) {
+ setInterval(function () {
+ if (document.hidden) return;
+ liveMembers.forEach(function (x) { refreshMember(x.m, x.refs); });
+ }, REFRESH_MS);
+ }
+})();