This commit is contained in:
Clove 2026-06-19 14:58:49 +01:00
parent ec7f9cf1a7
commit 44f47fe56b
3 changed files with 143 additions and 181 deletions

View File

@ -1996,16 +1996,39 @@ body:has(.friends-wrap) .hub {
box-shadow: 0 8px 22px -12px rgba(var(--accent-rgb), 0.5);
}
/* Nitro profile gradient — recolours the card body (mirrors the /discord card) */
.friend-card.has-profile-grad {
background: linear-gradient(180deg, rgb(var(--fc-grad-1-rgb)) 0%, rgb(var(--fc-grad-2-rgb)) 100%);
border-color: rgba(var(--fc-grad-1-rgb), 0.6);
}
/* Hovered card lifts above its neighbours and lets its text un-truncate so
long names / usernames / custom statuses expand in full instead of being
chopped off with an ellipsis. */
.friend-card:hover { z-index: 5; }
.friend-card:hover .fc-name,
.friend-card:hover .fc-user,
.friend-card:hover .fc-custom-text {
chopped off with an ellipsis. The body becomes an absolutely-positioned
overlay, so the card grows DOWNWARD without resizing its grid slot
neighbouring cards stay exactly where they are. */
/* Expansion is gated on .is-hovering (added by JS) rather than :hover so the
card's flow height can be pinned BEFORE .fc-main is taken out of flow
otherwise the card collapses, the page reflows, and hover ping-pongs
(the "vibrating page" bug). */
.friend-card.is-hovering { overflow: visible; z-index: 5; }
.friend-card.is-hovering .fc-main {
position: absolute;
left: 0;
right: 0;
top: 54px; /* sit directly under the banner strip */
background: inherit; /* carry the card surface / Nitro gradient */
border-bottom-left-radius: 14px;
border-bottom-right-radius: 14px;
box-shadow: 0 10px 24px -14px rgba(17, 17, 27, 0.8);
}
.friend-card.is-hovering .fc-name,
.friend-card.is-hovering .fc-user,
.friend-card.is-hovering .fc-custom-text {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere; /* break long unbroken usernames / handles */
}
/* banner strip — Nitro banner image / accent colour / default wash */
@ -2015,6 +2038,10 @@ body:has(.friends-wrap) .hub {
linear-gradient(135deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
background-size: cover;
background-position: center;
/* keep the banner corners rounded even on hover, when the card switches
to overflow:visible and no longer clips them to the card radius */
border-top-left-radius: 14px;
border-top-right-radius: 14px;
}
.friend-card.has-banner .fc-banner {
background-size: cover;
@ -2121,6 +2148,16 @@ body:has(.friends-wrap) .hub {
}
a.fc-name:hover { color: rgb(var(--accent-rgb)); }
/* Nitro display-name gradient (clipped to the text). Keep the tier heart in
::before painted normally so it stays its solid colour. */
.fc-name.is-gradient {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.fc-name.is-gradient::before { -webkit-text-fill-color: initial; color: var(--text); }
/* tier hearts (match the old friend-name prefixes) */
.fc-name::before { content: "🩵 "; }
.fc-name.known::before { content: "💛 "; }
@ -3530,15 +3567,20 @@ body:has(.presence-stage) {
padding: 0.9rem 1.4rem;
align-items: flex-end;
}
/* when a custom status sits in the identity column, top-align so the avatar
stays anchored to the name instead of being dragged down by the bubble */
/* keep the identity column bottom-aligned to the avatar's visible lower
half, whether or not a custom-status bubble is present */
.presence-stage .presence-card.has-custom .pc-head {
align-items: flex-start;
align-items: flex-end;
}
/* only the avatar pokes up into the banner; the name/identity block stays
in the solid area below it */
.presence-stage .presence-card.has-banner .pc-head,
.presence-stage .presence-card.has-banner-color .pc-head {
/* only the avatar pokes into the banner; the name/identity block clears it */
margin-top: -38px;
margin-top: 0;
}
.presence-stage .presence-card.has-banner .pc-avatar,
.presence-stage .presence-card.has-banner-color .pc-avatar {
margin-top: -60px;
}
.presence-stage .pc-avatar,
.presence-stage .presence-card.has-banner .pc-avatar,
@ -3616,7 +3658,9 @@ body:has(.presence-stage) {
.presence-stage .presence-card.has-banner .pc-av-img,
.presence-stage .presence-card.has-banner-color .pc-av-img { width: 92px; height: 92px; }
.presence-stage .presence-card.has-banner .pc-head,
.presence-stage .presence-card.has-banner-color .pc-head { margin-top: -30px; }
.presence-stage .presence-card.has-banner-color .pc-head { margin-top: 0; }
.presence-stage .presence-card.has-banner .pc-avatar,
.presence-stage .presence-card.has-banner-color .pc-avatar { margin-top: -46px; }
.presence-stage .pc-name { font-size: 1.25rem; }
}

View File

@ -301,32 +301,6 @@
connectionsEl.hidden = false;
}
function loadDstn() {
fetch("https://dcdn.dstn.to/profile/" + DISCORD_USER_ID)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j) return;
if (Array.isArray(j.badges)) { dstnBadges = j.badges; paintBadges(); }
if (j.user_profile && Array.isArray(j.user_profile.theme_colors)) {
applyProfileGradient(j.user_profile.theme_colors);
}
const u = j.user || {};
const prof = j.user_profile || {};
applyBanner(
bannerUrl(u.id || DISCORD_USER_ID, prof.banner || u.banner),
u.banner_color || (typeof u.accent_color === "number" ? intToHex(u.accent_color) : null)
);
renderBio(prof.bio || u.bio);
renderConnections(j.connected_accounts);
if (pronounsEl) {
const pr = prof.pronouns || u.pronouns;
if (pr) { pronounsEl.textContent = pr; pronounsEl.hidden = false; }
else pronounsEl.hidden = true;
}
})
.catch(function () {});
}
// ---- album-art → Catppuccin accent --------------------------------------
const ACCENT_VARS = [
"rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach",
@ -614,51 +588,12 @@
function startTicker() { if (!ticker) ticker = setInterval(updateTimes, 1000); }
function stopTicker() { if (ticker) { clearInterval(ticker); ticker = null; } }
// ---- Lanyard websocket --------------------------------------------------
function connect() {
ws = new WebSocket("wss://api.lanyard.rest/socket");
ws.addEventListener("message", (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch (e) { return; }
if (msg.op === 1) {
const interval = (msg.d && msg.d.heartbeat_interval) || 30000;
if (heartbeat) clearInterval(heartbeat);
heartbeat = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 3 }));
}, interval);
ws.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_USER_ID } }));
return;
}
if (msg.op === 0) {
const d = msg.t === "INIT_STATE" ? (msg.d && msg.d[DISCORD_USER_ID]) || msg.d : msg.d;
render(d);
}
});
ws.addEventListener("open", () => { reconnectDelay = 1000; });
ws.addEventListener("close", () => {
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
stopTicker();
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
ws.addEventListener("error", () => { try { ws.close(); } catch (e) {} });
}
// ---- data source: self-hosted API primary, Lanyard + dstn fallback ------
// The Doughmination Restful API returns presence + full profile in one call,
// so it drives the card on its own. We poll it; only if it can't serve us
// (network error, or no live presence) do we fail over to the Lanyard
// websocket + dstn.to, the same chain the friends grid uses.
// ---- 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 = 20000; // own-presence refresh cadence
const SELF_GIVE_UP = 3; // consecutive misses before failing over
let selfTimer = null;
let selfMisses = 0;
let lanyardActive = false;
// self-host shape -> the Lanyard-shaped object render() already understands
function mapSelfHostToLanyard(j) {
@ -699,6 +634,9 @@
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(
@ -717,39 +655,21 @@
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) {
// need a live presence object to drive the card; else fall back
if (!j || !j.success || !j.data || !j.data.user || !j.data.presence) return false;
// 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 startLanyardFallback() {
if (lanyardActive) return;
lanyardActive = true;
if (selfTimer) { clearInterval(selfTimer); selfTimer = null; }
connect();
loadDstn();
}
function pollSelfHost() {
if (document.hidden) return;
loadSelfHosted().then(function (ok) {
if (ok) { selfMisses = 0; return; }
if (++selfMisses >= SELF_GIVE_UP) startLanyardFallback();
});
if (!document.hidden) loadSelfHosted();
}
// boot: Doughmination first; Lanyard/dstn only if it can't serve us
loadSelfHosted().then(function (ok) {
if (ok) {
selfMisses = 0;
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS);
} else {
startLanyardFallback();
}
});
// boot: poll the Doughmination Restful API (the only source now).
loadSelfHosted();
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS);
document.addEventListener("visibilitychange", () => {
if (!document.hidden && latest) updateTimes();

View File

@ -75,6 +75,55 @@
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) {
@ -214,8 +263,21 @@
'</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"),
@ -280,6 +342,8 @@
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) {
@ -290,91 +354,25 @@
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; });
}
// ---- 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);
// avatar decoration (animated APNG) — loaded straight from Discord's
// CDN; the wsrv webp proxy fails on these and would drop the animation.
var deco = u.avatar_decoration_data;
if (refs.deco) {
if (deco && deco.asset) {
refs.deco.src = "https://cdn.discordapp.com/avatar-decoration-presets/" + deco.asset + ".png";
refs.deco.hidden = false;
} else {
refs.deco.hidden = true;
}
}
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 () {});
}
// Self-hosted API is the primary source; Lanyard + dstn.to are fallbacks.
// Resolves to true if ANY source had data for this member, false if they
// were not found anywhere — callers use that to stop re-polling 404s.
// ---- 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) {
if (result === "full") return true; // self-host covered everything
if (result === "profile") {
// profile came from self-host but it isn't tracking live presence —
// pull the live status dot + activity/custom status from Lanyard only.
loadLanyard(m, refs);
return true;
}
// result === false → self-host had nothing: original Lanyard + dstn path
return loadLanyard(m, refs).then(function (live) {
if (live) { loadDstn(m, refs); return true; }
return false; // not found in self-host OR Lanyard
});
return result !== false;
});
}