diff --git a/assets/selfies/IMG_3843.jpg b/assets/selfies/IMG_3843.jpg new file mode 100644 index 0000000..0246c20 Binary files /dev/null and b/assets/selfies/IMG_3843.jpg differ diff --git a/assets/selfies/README.md b/assets/selfies/README.md deleted file mode 100644 index 2458e9a..0000000 --- a/assets/selfies/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# /assets/selfies - -Drop your selfie image files in this folder, then list them in `selfies.json`. -The gallery at `/selfies` is rendered from that manifest by `/js/selfies.js`. - -## Adding a selfie - -1. Put the image file in this folder, e.g. `assets/selfies/2026-06-clove.jpg`. -2. Add an entry to `selfies.json`. **The list is shown newest-first β€” put the - newest selfie at the top.** - -`selfies.json` is a plain JSON array. Each entry can be either: - -- **a filename string** (no caption, alt text auto-generated): - - ```json - [ - "2026-06-clove.jpg", - "2026-05-night-out.png" - ] - ``` - -- **or an object** with optional `caption` and `alt`: - - ```json - [ - { "src": "2026-06-clove.jpg", "caption": "golden hour β˜€οΈ", "alt": "Clove smiling in a sunlit park" }, - { "src": "2026-05-night-out.png", "caption": "night out πŸ’ƒ" } - ] - ``` - -You can mix both styles in the same list. - -## Fields - -- **`src`** (required) β€” the image. A bare filename resolves to - `/assets/selfies/`. You can also give a full path (`/assets/...`) - or an absolute URL (`https://...`). -- **`caption`** (optional) β€” short text shown **under the thumbnail and under - the enlarged photo in the lightbox**. Leave it out for no caption. -- **`alt`** (optional) β€” accessibility text for screen readers only (not shown - on screen). If omitted, it falls back to the caption, then to a generic - "Selfie N of Clove Twilight". - -## Notes - -- Common web formats work: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`. -- Until you add at least one entry, the page shows a friendly "no selfies yet" - message. diff --git a/assets/selfies/selfies.json b/assets/selfies/selfies.json index e38f78a..51f2f54 100644 --- a/assets/selfies/selfies.json +++ b/assets/selfies/selfies.json @@ -1,3 +1,4 @@ [ - { "src": "image.png", "alt": "Selfie of Clove Twilight", "caption": "Selfie taken after I fully moved into uni ✨" } + { "src": "image.png", "alt": "Selfie of Clove Twilight", "caption": "Selfie taken after I fully moved into uni ✨" }, + { "src": "IMG_3843.jpg", "alt":"Selfie of Clove Twilight", "caption": "Walking outside my uni"} ] diff --git a/cool-people/index.html b/cool-people/index.html index 1cbbe1f..7aae02b 100644 --- a/cool-people/index.html +++ b/cool-people/index.html @@ -12,6 +12,10 @@ + + + + Clove Twilight @@ -102,6 +106,8 @@ + + diff --git a/css/main.css b/css/main.css index 43a6128..da18606 100644 --- a/css/main.css +++ b/css/main.css @@ -4080,3 +4080,94 @@ body.lightbox-open { .lightbox-caption[hidden] { display: none; } + + +/* ============================================================ + 15. Cool-people friend cards β€” mini presence cards + Each friend is a full presence card (built by discord.js's + PresenceCard factory) but smaller than the big /discord one. + The BASE .presence-card is already compact (β‰ˆ280px); we just + un-fix it from the corner so the cards tile in .friend-grid, + and re-add the tier hearts / dead-alt treatment on pc-* markup. + ============================================================ */ +.presence-card.is-mini { + position: static; + top: auto; + left: auto; + right: auto; + bottom: auto; + z-index: auto; + margin: 0; + width: 300px; /* clearly smaller than the 680px /discord card */ + max-width: 100%; +} + +/* keep things tidy at the small size */ +.presence-card.is-mini .pc-banner { height: 84px; } +.presence-card.is-mini .pc-bio { + max-height: 6.5em; + overflow-y: auto; +} + +/* the friend name can open a personal site */ +.presence-card.is-mini .pc-name--link { text-decoration: none; } +.presence-card.is-mini .pc-name--link:hover { text-decoration: underline; } + +/* ---- tier hearts (ported from the old .fc-name prefixes) ---- */ +.presence-card.is-mini .pc-name::before { content: "🩡 "; } +.presence-card.is-mini.tier-known .pc-name::before { content: "πŸ’› "; } +.presence-card.is-mini.tier-wife .pc-name::before { content: "πŸ–€ "; } +.presence-card.is-mini.tier-close .pc-name::before { content: "🀍 "; } +.presence-card.is-mini.tier-active-alt .pc-name::before { content: "🎭 "; } +.presence-card.is-mini.tier-dead-alt .pc-name::before { content: "πŸ’€ "; } +/* gradient names clip text to transparent β€” keep the heart visible */ +.presence-card.is-mini .pc-name.is-gradient::before { + -webkit-text-fill-color: initial; + color: var(--text); +} + +/* ---- dead alts: greyed, struck through, no live status ---- */ +.presence-card.is-mini.tier-dead-alt .pc-av-img { filter: grayscale(1) brightness(0.6); } +.presence-card.is-mini.tier-dead-alt .pc-name { + color: var(--overlay-1); + text-decoration: line-through; +} +.presence-card.is-mini.tier-dead-alt .pc-status { display: none; } + + +/* ============================================================ + 16. Top artists β€” artist avatar in each chip (Deezer images) + ============================================================ */ +.top-chip a { align-items: center; } + +.top-art { + width: 34px; + height: 34px; + border-radius: 50%; + object-fit: cover; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface-0); + color: var(--subtext-0); + font-size: 0.95rem; + overflow: hidden; +} + +.top-text { + display: inline-flex; + flex-direction: column; + line-height: 1.2; + min-width: 0; +} + + +/* connection brand logos (replaces the old text type label) */ +.pc-conn-ic { + width: 14px; + height: 14px; + display: block; + flex: none; +} +.presence-card.is-mini .pc-conn-ic { width: 13px; height: 13px; } diff --git a/js/discord.js b/js/discord.js index 6b4fb72..a7432f4 100644 --- a/js/discord.js +++ b/js/discord.js @@ -1,4 +1,4 @@ -(function presence() { +(function () { "use strict"; // ---- who are we showing? ------------------------------------------------ @@ -18,9 +18,22 @@ return null; } - const DISCORD_USER_ID = resolveUserId(); - const mount = document.getElementById("discord"); - if (!mount || !DISCORD_USER_ID) return; + // Build one presence card. Used full-size on /discord, and as small clones + // on /cool-people (opts.mini). Options: + // userId Discord id to show (defaults to resolveUserId()) + // mount element to replace with the card (defaults to #discord) + // mini true β†’ adds .is-mini for smaller styling + // tier / link friend tier class + optional website link on the name + // pollMs presence refresh cadence (default 20s) + // fallbackName / fallbackImg + // shown immediately, and kept if the API has no data for them + // (lets ID-less / dead alts still render a card) + function createPresenceCard(opts) { + opts = opts || {}; + const DISCORD_USER_ID = opts.userId || resolveUserId(); + const mount = opts.mount || document.getElementById("discord"); + if (!mount) return null; + if (!DISCORD_USER_ID && !opts.fallbackName) return null; // ---- theme: only on standalone api pages (homepage uses data-flavor) ---- if (!document.documentElement.getAttribute("data-flavor")) { @@ -33,8 +46,10 @@ // ---- build the card ----------------------------------------------------- const card = document.createElement("div"); - card.id = "discord"; - card.className = "presence-card"; + // Only the single owner card claims id="discord" (core.js + the gold-cat + // observer key off it). Mini friend cards must not duplicate the id. + if (!opts.mini) card.id = "discord"; + card.className = "presence-card" + (opts.mini ? " is-mini" : "") + (opts.tier ? " tier-" + opts.tier : ""); card.hidden = true; card.innerHTML = '' + @@ -85,6 +100,28 @@ const connectionsEl = card.querySelector(".pc-connections"); const pronounsEl = card.querySelector(".pc-pronouns"); + // ---- friend-card extras: name link + instant placeholder ---------------- + // Optional website link on the name (friends can have a personal site). + if (opts.link) { + nameEl.classList.add("pc-name--link"); + nameEl.style.cursor = "pointer"; + nameEl.setAttribute("role", "link"); + nameEl.setAttribute("tabindex", "0"); + const goLink = function () { window.open(opts.link, "_blank", "noopener"); }; + nameEl.addEventListener("click", goLink); + nameEl.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goLink(); } + }); + } + // Seed a placeholder so the card shows instantly (and remains for ID-less or + // offline dead alts the API can't fill). render() overwrites it on success. + if (opts.fallbackName) { + nameEl.textContent = opts.fallbackName; + avImg.src = opts.fallbackImg || avatarUrl({ id: DISCORD_USER_ID }); + card.dataset.status = "offline"; + card.hidden = false; + } + // ---- wishlist (revealed by the star) ------------------------------------ // Items come straight from the Doughmination Restful API (j.data.wishlist): // each is a resolved Shop item { sku_id, type, name, static_image_url, @@ -321,6 +358,25 @@ domain: function (n) { return "https://" + n; }, bluesky: function (n) { return "https://bsky.app/profile/" + n; } }; + // connection type -> brand SVG in /assets/socials (anything unmapped uses + // the generic globe "site.svg") + const CONNECTION_ICON = { + twitter: "twitter", + bluesky: "bluesky", + mastodon: "mastodon", + twitch: "twitch", + youtube: "youtube", + reddit: "reddit", + spotify: "spotify", + discord: "discord", + linkedin: "linkedin", + domain: "site" + }; + function connIcon(type) { + const file = CONNECTION_ICON[String(type || "").toLowerCase()] || "site"; + return '' +
+      esc(type) + ''; + } function renderConnections(accounts) { if (!connectionsEl) return; const list = (accounts || []).filter(function (a) { return a && a.name; }); @@ -328,7 +384,7 @@ connectionsEl.innerHTML = list.map(function (a) { const maker = CONNECTION_URLS[a.type]; const url = maker ? maker(a.name, a.id) : null; - const inner = '' + esc(a.type) + "" + + const inner = connIcon(a.type) + '' + esc(a.name) + "" + (a.verified ? 'βœ“' : ""); return url @@ -395,7 +451,9 @@ const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`; card.style.setProperty("--dc-accent", rgb); card.classList.add("has-accent"); - document.documentElement.style.setProperty("--accent-rgb", rgb); + // Only the full-size card recolours the whole page; mini friend cards + // keep their accent local so they don't fight over --accent-rgb. + if (!opts.mini) document.documentElement.style.setProperty("--accent-rgb", rgb); } catch (e) { resetAccent(); } }; img.onerror = resetAccent; @@ -405,7 +463,7 @@ lastArtUrl = null; card.classList.remove("has-accent"); card.style.removeProperty("--dc-accent"); - document.documentElement.style.removeProperty("--accent-rgb"); + if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb"); } // ---- section (row) builders -------------------------------------------- @@ -647,7 +705,7 @@ // 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_POLL_MS = opts.pollMs || 20000; // presence refresh cadence let selfTimer = null; // self-host shape -> the Lanyard-shaped object render() already understands @@ -729,11 +787,24 @@ if (!document.hidden) loadSelfHosted(); } - // boot: poll the Doughmination Restful API (the only source now). - loadSelfHosted(); - selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); + // boot: poll the Doughmination Restful API (the only source now). ID-less + // placeholder cards (e.g. dead alts) keep their seeded look β€” no fetch. + if (DISCORD_USER_ID) { + loadSelfHosted(); + selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); + } document.addEventListener("visibilitychange", () => { if (!document.hidden && latest) updateTimes(); }); + + return card; + } // ---- end createPresenceCard ---- + + // Expose the factory so other pages (e.g. /cool-people) can build cards. + window.PresenceCard = createPresenceCard; + + // Auto-mount the standalone card whenever its #discord placeholder exists, + // preserving the original /discord page behaviour exactly. + if (document.getElementById("discord")) createPresenceCard({}); })(); \ No newline at end of file diff --git a/js/friends.js b/js/friends.js index 1ba0d6d..470f16b 100644 --- a/js/friends.js +++ b/js/friends.js @@ -1,6 +1,12 @@ (function friends() { "use strict"; + // Each friend is rendered as a full β€” but smaller β€” presence card, built by + // the shared factory in discord.js (window.PresenceCard). Cards pull live + // presence (status, activity, badges, banner, bio, connections, wishlist…) + // from the same Doughmination Restful API the /discord card uses. + // NOTE: discord.js must be loaded BEFORE this file (see cool-people/index.html). + var FRIENDS = [ { title: "FiancΓ©e", @@ -51,7 +57,7 @@ } ]; - var REFRESH_MS = 60000; // re-poll live friends once a minute + var FRIEND_POLL_MS = 60000; // re-poll each live friend once a minute var root = document.getElementById("friends-root"); if (!root) return; @@ -65,324 +71,12 @@ .replace(/^-+|-+$/g, ""); } - // ---- helpers (mirrors discord.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); - } - 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 display-name fonts (display_name_styles.font_id) -> the local - // look-alike @font-face families in css/fonts.css (loaded site-wide via - // main.css @import). Mapping confirmed against Discord's font_id enum; - // mirrors NAME_FONTS in js/discord.js. (1/2/5/9 have no file -> default.) - var FONT_BY_ID = { - 3: "'DDN Sakura', cursive", // CHERRY_BOMB - 4: "'DDN Jellybean', cursive", // CHICLE - 6: "'DDN Modern', sans-serif", // MUSEO_MODERNO - 7: "'DDN Medieval', serif", // NEO_CASTEL - 8: "'DDN 8Bit', monospace", // PIXELIFY - 10: "'DDN Vampyre', serif", // SINISTRE - 11: "'DDN gg sans', sans-serif", // DEFAULT - 12: "'DDN Tempo', serif" // ZILLA_SLAB - }; - 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"); - } - // custom display-name font (local @font-face; no network load needed) - var fam = styles.font_id && FONT_BY_ID[styles.font_id]; - if (fam) { 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) { - 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 - ? '' - : (emoji && emoji.name ? '' + esc(emoji.name) + "" : ""); - refs.custom.innerHTML = emojiHtml + (text ? '' + esc(text) + "" : ""); - 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 = '' + esc(b.description || b.id) + ''; - return b.link - ? '' + img + "" - : img; - }).join(""); - } - - // Self-hosted badge list β†’ small icon row. The self-hosted API ships a - // ready-made `icon_url`; fall back to building it from `icon` if absent. - function renderSelfBadges(badges) { - if (!Array.isArray(badges)) return ""; - return badges.map(function (b) { - var url = b.icon_url || ("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png"); - var img = '' + esc(b.description || b.id) + ''; - return b.link - ? '' + img + "" - : img; - }).join(""); - } - - // Self-hosted clan tag β†’ guild badge + tag. Shape: {tag, badge_url}. - function renderSelfClan(refs, clan) { - if (!refs.tag) return; - if (clan && clan.tag) { - var b = clan.badge_url ? proxyImg(clan.badge_url) : null; - refs.tag.innerHTML = (b ? '' : "") + - '' + esc(clan.tag) + ""; - refs.tag.hidden = false; - } else { - refs.tag.hidden = true; - } - } - - // 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 ? '' : "") + - '' + esc(pg.tag) + ""; - 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 = - '
' + - '
' + - '' + - '' + esc(m.name) + '' + - '' + - '' + - '' + - '' + - '' + - '<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
'; - - // 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"), - 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: self-hosted API (primary source) ------------------- - // Returns a promise resolving to: - // "full" β†’ user + live presence rendered (no fallback needed) - // "profile" β†’ user rendered but presence was null (fall back to Lanyard - // for the live status dot + current activity/custom status) - // false β†’ nothing usable (full Lanyard + dstn fallback) - var SELF_BASE = "https://restful.doughmination.uk/v1/users/"; - - function loadSelfHosted(m, refs) { - return fetch(SELF_BASE + m.discordId, { cache: "no-store" }) - .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; }) - .then(function (j) { - if (!j || !j.success || !j.data || !j.data.user) return false; - var u = j.data.user || {}; - var p = j.data.presence; // may be null when the user isn't live-tracked - var havePresence = !!p; - - // --- profile (rendered whenever we have a user object) --- - if (u.avatar) { - refs.pfp.src = avatarUrl({ id: u.id || m.discordId, avatar: u.avatar }); - } - // avatar decoration β€” load straight from Discord's CDN (the wsrv webp - // proxy drops the APNG animation). API ships a ready `url`. - if (refs.deco) { - var deco = u.avatar_decoration; - var dUrl = deco && (deco.url || - (deco.asset ? "https://cdn.discordapp.com/avatar-decoration-presets/" + deco.asset + ".png" : null)); - if (dUrl) { refs.deco.src = dUrl; refs.deco.hidden = false; } - else { refs.deco.hidden = true; } - } - if (u.username) refs.user.textContent = "@" + u.username; - renderSelfClan(refs, u.clan); - if (Array.isArray(j.data.badges) && j.data.badges.length) { - refs.badges.innerHTML = renderSelfBadges(j.data.badges); - } - // banner β€” rebuild from the raw hash via bannerUrl() rather than the - // API's banner_url, which uses a `.gif` extension that 415s for some - // animated banners. Falls back to accent colour. - var bUrl = bannerUrl(u.id || m.discordId, u.banner); - if (bUrl) { - refs.banner.style.backgroundImage = "url('" + bUrl + "')"; - 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"); - } - if (Array.isArray(u.theme_colors)) applyGrad(refs, u.theme_colors); - applyNameStyle(refs, u.display_name_styles); - - // --- live presence --- - if (havePresence) { - var status = p.status || (p.online ? "online" : "offline"); - refs.el.dataset.status = status; - refs.statusDot.title = STATUS_TITLE[status] || "Offline"; - // activities follow the standard Discord shape (type 4 = custom status) - 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; }); - } - - // ---- 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) { - return result !== false; - }); - } - - // Stop polling a member after this many consecutive "not found anywhere" - // results. Keeps 404s (e.g. someone not in the self-host DB or Lanyard) - // from spamming the console on every refresh. Any successful resolve resets - // the counter, so a transient blip won't permanently drop a friend. Tunable. - var GIVE_UP_AFTER = 3; - - function pollEntry(entry) { - if (entry.stop) return; - refreshMember(entry.m, entry.refs).then(function (resolved) { - if (resolved) { entry.misses = 0; return; } - entry.misses++; - if (entry.misses >= GIVE_UP_AFTER) entry.stop = true; // give up for this session - }); + var make = window.PresenceCard; + if (typeof make !== "function") { + console.error("friends.js: window.PresenceCard is missing β€” load /js/discord.js before /js/friends.js"); } // ---- render --------------------------------------------------------- - var liveMembers = []; // {m, refs} for those with an id, for polling - FRIENDS.forEach(function (group) { var section = document.createElement("section"); section.className = "section"; @@ -404,14 +98,26 @@ grid.className = "friend-grid"; group.members.forEach(function (m) { - var refs = buildCard(m); - grid.appendChild(refs.el); - // poll anyone with an ID β€” even "dead" alts can be semi-alive. Accounts - // that 404 everywhere are dropped automatically by the give-up logic. - if (m.discordId) { - var entry = { m: m, refs: refs, misses: 0, stop: false }; - liveMembers.push(entry); - pollEntry(entry); + // placeholder slot β€” the factory replaces it with the finished card + var slot = document.createElement("div"); + grid.appendChild(slot); + + if (typeof make === "function") { + make({ + mount: slot, + userId: m.discordId || null, // null β†’ static placeholder card (dead alts) + mini: true, // smaller styling + keeps page accent local + pollMs: FRIEND_POLL_MS, + tier: m.tier || null, + link: m.link || null, + fallbackName: m.name, // shown instantly + kept if the API has no data + fallbackImg: m.img || null + }); + } else { + // hard fallback: at least show the name if the factory didn't load + slot.className = "presence-card is-mini" + (m.tier ? " tier-" + m.tier : ""); + slot.dataset.status = "offline"; + slot.textContent = m.name; } }); @@ -428,12 +134,4 @@ } scrollToHash(); window.addEventListener("hashchange", scrollToHash); - - // ---- poll live members ---------------------------------------------- - if (liveMembers.length) { - setInterval(function () { - if (document.hidden) return; - liveMembers.forEach(function (x) { if (!x.stop) pollEntry(x); }); - }, REFRESH_MS); - } })(); diff --git a/js/music.js b/js/music.js index d09c580..2246dde 100644 --- a/js/music.js +++ b/js/music.js @@ -379,9 +379,16 @@ } // ======================================================================= - // LANYARD β€” live Discord presence (same socket as the card) + // PRESENCE β€” Doughmination Restful API (replaces the Lanyard socket) // ======================================================================= - let ws = null, heartbeat = null, retry = 0; + // Same now-playing data, pulled from the self-hosted API the rest of the + // site uses. It's request/response (not a socket), so we poll; the tick() + // loop interpolates the progress bar + synced lyrics smoothly between polls + // using Spotify's start/end timestamps, so playback still feels live. + const SELF_BASE = "https://restful.doughmination.uk/v1/users/"; + const PRESENCE_POLL_MS = 10000; + let presenceTimer = null; + function fromSpotify(s) { return { song: s.song, artist: s.artist, album: s.album, @@ -405,28 +412,23 @@ showIdle(); } } - function connectLanyard() { - try { ws = new WebSocket("wss://api.lanyard.rest/socket"); } - catch (e) { return; } - ws.onmessage = (ev) => { - let msg; try { msg = JSON.parse(ev.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_ID } })); - } else if (msg.op === 0 && (msg.t === "INIT_STATE" || msg.t === "PRESENCE_UPDATE")) { - onPresence(msg.d); - } - }; - ws.onclose = () => { - if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } - retry = Math.min(retry + 1, 6); - setTimeout(connectLanyard, 1000 * retry); - }; - ws.onerror = () => { if (ws) ws.close(); }; + function pollPresence() { + if (document.hidden) return; // don't poll a backgrounded tab + fetch(SELF_BASE + DISCORD_ID, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (!j || !j.success || !j.data) return; + onPresence(j.data.presence || null); // presence is null when offline + }) + .catch(() => { /* network blip β€” keep last state, retry next poll */ }); + } + function startPresence() { + pollPresence(); + presenceTimer = setInterval(pollPresence, PRESENCE_POLL_MS); + // refresh the moment the tab comes back into focus + document.addEventListener("visibilitychange", () => { + if (!document.hidden) pollPresence(); + }); } // ======================================================================= @@ -514,6 +516,34 @@ } } + // Last.fm stopped serving artist images β€” every artist returns the same + // placeholder star β€” so we pull them from Deezer. Deezer's REST API blocks + // CORS, but its JSONP mode (output=jsonp) doesn't. Resolves to a picture URL + // (or "" when there's no match / it fails). + function deezerArtistImg(name) { + return new Promise(function (resolve) { + if (!name) { resolve(""); return; } + var cb = "__dz_" + Math.random().toString(36).slice(2); + var s = document.createElement("script"); + var done = false; + function finish(url) { + if (done) return; + done = true; + try { delete window[cb]; s.remove(); } catch (e) { /* no-op */ } + resolve(url || ""); + } + window[cb] = function (data) { + var a = data && data.data && data.data[0]; + finish(a ? (a.picture_medium || a.picture || "") : ""); + }; + s.onerror = function () { finish(""); }; + s.src = "https://api.deezer.com/search/artist?q=" + encodeURIComponent(name) + + "&limit=1&output=jsonp&callback=" + cb; + document.head.appendChild(s); + setTimeout(function () { finish(""); }, 6000); + }); + } + async function loadTop() { if (!LFM_OK || !topBox) { if (topBox) topBox.hidden = true; return; } try { @@ -525,9 +555,27 @@ '
    ' + arr.map((a, i) => '
  1. ' + '' + (i + 1) + "" + - '' + esc(a.name) + "" + - '' + esc(a.playcount) + " plays" + + '' + + '' + + '' + esc(a.name) + "" + + '' + esc(a.playcount) + " plays" + + "" + "
  2. ").join("") + "
"; + // chips are already visible β€” fill in the artist images as Deezer answers + const chips = topBox.querySelectorAll(".top-chip"); + arr.forEach((a, i) => { + deezerArtistImg(a.name).then((url) => { + if (!url) return; + const slot = chips[i] && chips[i].querySelector(".top-art"); + if (!slot) return; + const img = new Image(); + img.className = "top-art"; + img.alt = ""; + img.referrerPolicy = "no-referrer"; + img.src = url; + slot.replaceWith(img); + }); + }); } catch (e) { topBox.hidden = true; } } @@ -535,8 +583,8 @@ // boot // ======================================================================= paintHero(); - showIdle(); // headline + lyrics before the socket warms up - connectLanyard(); // takes over the hero the moment a presence arrives + showIdle(); // headline + lyrics before the first poll lands + startPresence(); // takes over the hero whenever a live Spotify track is found loadRecent(); loadTop(); requestAnimationFrame(tick); diff --git a/js/terminal.js b/js/terminal.js index a8fee55..4b42d7f 100644 --- a/js/terminal.js +++ b/js/terminal.js @@ -2,42 +2,14 @@ * terminal.js β€” the homepage's interactive terminal. * * Flow: a short boot log streams in, the side chrome fades in alongside - * it, then the banner + a pinned prompt appear. You type a command (or a - * social's name) and the output is appended to the scrollback BELOW the - * input β€” the input itself never moves. + * it, then the banner + a pinned prompt appear. You type a command and + * the output is appended to the scrollback BELOW the input β€” the input + * itself never moves. * ===================================================================== */ (function terminal() { const root = document.getElementById("terminal"); if (!root) return; - // ---- socials (keyword -> destination) ---------------------------------- - const SOCIALS = { - gitgay: { label: "Git.Gay", sub: "@doughmination", url: "https://git.gay/doughmination", aliases: ["git.gay", "gitea", "github", "git"] }, - twitter: { label: "Twitter", sub: "@DoughminCEO", url: "https://x.com/DoughminCEO", aliases: ["x"] }, - bluesky: { label: "Bluesky", sub: "@doughmination.win", url: "https://bsky.app/profile/doughmination.win", aliases: ["bsky"] }, - linkedin: { label: "LinkedIn", sub: "Clove Twilight", url: "https://www.linkedin.com/in/estrogen/" }, - spotify: { label: "Spotify", sub: "doughmination", url: "https://open.spotify.com/user/x060f5w4ftwv8zc8fi9662t70" }, - discord: { label: "Discord", sub: "Doughmination", url: "https://discord.gg/YtJayCYEw5" }, - twitch: { label: "Twitch", sub: "@doughminationgaming", url: "https://www.twitch.tv/doughminationgaming" }, - reddit: { label: "Reddit", sub: "u/XerinDotZero", url: "https://www.reddit.com/user/XerinDotZero/" }, - youtube: { label: "YouTube", sub: "@CloveTwiGaming", url: "https://www.youtube.com/@CloveTwiGaming", aliases: ["yt"] }, - mastodon: { label: "Mastodon", sub: "@doughmination@mastodon.social", url: "https://mastodon.social/@doughmination" }, - email: { label: "Email", sub: "admin@doughmination.win", url: "mailto:admin@doughmination.win", aliases: ["mail"] } - }; - const ALIASES = {}; - Object.keys(SOCIALS).forEach((k) => { - (SOCIALS[k].aliases || []).forEach((a) => { ALIASES[a] = k; }); - }); - // keyword -> svg filename in /assets/socials - const SOCIAL_ICON = { - github: "github", gitgay: "git-gay", twitter: "twitter", bluesky: "bluesky", - linkedin: "linkedin", spotify: "spotify", discord: "discord", twitch: "twitch", - reddit: "reddit", youtube: "youtube", mastodon: "mastodon", email: "email" - }; - function iconImg(key) { - return ''; - } - // arch.ascii (hyfetch format) is fetched once at startup for `hyfetch`. let archLines = null; function loadArt() { @@ -83,7 +55,7 @@ '' + '