Fixes
This commit is contained in:
parent
ec7f9cf1a7
commit
44f47fe56b
66
css/main.css
66
css/main.css
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
104
js/discord.js
104
js/discord.js
|
|
@ -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();
|
||||
|
|
|
|||
154
js/friends.js
154
js/friends.js
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue