diff --git a/css/main.css b/css/main.css index 38d1b83..cb65d18 100644 --- a/css/main.css +++ b/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; } } diff --git a/js/discord.js b/js/discord.js index 107e8a3..0a3cf04 100644 --- a/js/discord.js +++ b/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(); diff --git a/js/friends.js b/js/friends.js index 067bd53..4f4debb 100644 --- a/js/friends.js +++ b/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 @@ '' + ''; + // 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; }); }