stupid.cat

This commit is contained in:
Clove 2026-06-21 03:34:24 +01:00
parent ab5fd681b6
commit 465c1886fc
10 changed files with 302 additions and 523 deletions

BIN
assets/selfies/IMG_3843.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@ -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/<filename>`. 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.

View File

@ -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"}
] ]

View File

@ -12,6 +12,10 @@
<link rel="dns-prefetch" href="https://wsrv.nl"> <link rel="dns-prefetch" href="https://wsrv.nl">
<link rel="preconnect" href="https://cdn.discordapp.com"> <link rel="preconnect" href="https://cdn.discordapp.com">
<link rel="dns-prefetch" href="https://cdn.discordapp.com"> <link rel="dns-prefetch" href="https://cdn.discordapp.com">
<link rel="preconnect" href="https://i.scdn.co">
<link rel="dns-prefetch" href="https://i.scdn.co">
<link rel="preconnect" href="https://media.discordapp.net">
<link rel="dns-prefetch" href="https://media.discordapp.net">
<title>Clove Twilight</title> <title>Clove Twilight</title>
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
@ -102,6 +106,8 @@
</div> </div>
<script src="/js/core.js" data-cat="/assets/oneko/classics/classic.png"></script> <script src="/js/core.js" data-cat="/assets/oneko/classics/classic.png"></script>
<!-- discord.js exposes window.PresenceCard, used by friends.js for the cards -->
<script src="/js/discord.js"></script>
<script src="/js/friends.js"></script> <script src="/js/friends.js"></script>
</body> </body>

View File

@ -4080,3 +4080,94 @@ body.lightbox-open {
.lightbox-caption[hidden] { .lightbox-caption[hidden] {
display: none; 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; }

View File

@ -1,4 +1,4 @@
(function presence() { (function () {
"use strict"; "use strict";
// ---- who are we showing? ------------------------------------------------ // ---- who are we showing? ------------------------------------------------
@ -18,9 +18,22 @@
return null; return null;
} }
const DISCORD_USER_ID = resolveUserId(); // Build one presence card. Used full-size on /discord, and as small clones
const mount = document.getElementById("discord"); // on /cool-people (opts.mini). Options:
if (!mount || !DISCORD_USER_ID) return; // 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) ---- // ---- theme: only on standalone api pages (homepage uses data-flavor) ----
if (!document.documentElement.getAttribute("data-flavor")) { if (!document.documentElement.getAttribute("data-flavor")) {
@ -33,8 +46,10 @@
// ---- build the card ----------------------------------------------------- // ---- build the card -----------------------------------------------------
const card = document.createElement("div"); const card = document.createElement("div");
card.id = "discord"; // Only the single owner card claims id="discord" (core.js + the gold-cat
card.className = "presence-card"; // 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.hidden = true;
card.innerHTML = card.innerHTML =
'<img class="pc-banner" alt="" referrerpolicy="no-referrer" hidden>' + '<img class="pc-banner" alt="" referrerpolicy="no-referrer" hidden>' +
@ -85,6 +100,28 @@
const connectionsEl = card.querySelector(".pc-connections"); const connectionsEl = card.querySelector(".pc-connections");
const pronounsEl = card.querySelector(".pc-pronouns"); 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) ------------------------------------ // ---- wishlist (revealed by the star) ------------------------------------
// Items come straight from the Doughmination Restful API (j.data.wishlist): // Items come straight from the Doughmination Restful API (j.data.wishlist):
// each is a resolved Shop item { sku_id, type, name, static_image_url, // each is a resolved Shop item { sku_id, type, name, static_image_url,
@ -321,6 +358,25 @@
domain: function (n) { return "https://" + n; }, domain: function (n) { return "https://" + n; },
bluesky: function (n) { return "https://bsky.app/profile/" + 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 '<img class="pc-conn-ic" src="/assets/socials/' + file + '.svg" alt="' +
esc(type) + '" title="' + esc(type) + '" loading="lazy" onerror="this.remove()">';
}
function renderConnections(accounts) { function renderConnections(accounts) {
if (!connectionsEl) return; if (!connectionsEl) return;
const list = (accounts || []).filter(function (a) { return a && a.name; }); const list = (accounts || []).filter(function (a) { return a && a.name; });
@ -328,7 +384,7 @@
connectionsEl.innerHTML = list.map(function (a) { connectionsEl.innerHTML = list.map(function (a) {
const maker = CONNECTION_URLS[a.type]; const maker = CONNECTION_URLS[a.type];
const url = maker ? maker(a.name, a.id) : null; const url = maker ? maker(a.name, a.id) : null;
const inner = '<span class="pc-conn-type">' + esc(a.type) + "</span>" + const inner = connIcon(a.type) +
'<span class="pc-conn-name">' + esc(a.name) + "</span>" + '<span class="pc-conn-name">' + esc(a.name) + "</span>" +
(a.verified ? '<span class="pc-conn-check" title="Verified">✓</span>' : ""); (a.verified ? '<span class="pc-conn-check" title="Verified">✓</span>' : "");
return url return url
@ -395,7 +451,9 @@
const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`; const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`;
card.style.setProperty("--dc-accent", rgb); card.style.setProperty("--dc-accent", rgb);
card.classList.add("has-accent"); 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(); } } catch (e) { resetAccent(); }
}; };
img.onerror = resetAccent; img.onerror = resetAccent;
@ -405,7 +463,7 @@
lastArtUrl = null; lastArtUrl = null;
card.classList.remove("has-accent"); card.classList.remove("has-accent");
card.style.removeProperty("--dc-accent"); card.style.removeProperty("--dc-accent");
document.documentElement.style.removeProperty("--accent-rgb"); if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb");
} }
// ---- section (row) builders -------------------------------------------- // ---- section (row) builders --------------------------------------------
@ -647,7 +705,7 @@
// Returns presence + full profile (incl. theme_colors + display_name_styles) // Returns presence + full profile (incl. theme_colors + display_name_styles)
// in a single call. Lanyard + dstn.to were removed. // in a single call. Lanyard + dstn.to were removed.
const SELF_BASE = "https://restful.doughmination.uk/v1/users/"; 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; let selfTimer = null;
// self-host shape -> the Lanyard-shaped object render() already understands // self-host shape -> the Lanyard-shaped object render() already understands
@ -729,11 +787,24 @@
if (!document.hidden) loadSelfHosted(); if (!document.hidden) loadSelfHosted();
} }
// boot: poll the Doughmination Restful API (the only source now). // boot: poll the Doughmination Restful API (the only source now). ID-less
loadSelfHosted(); // placeholder cards (e.g. dead alts) keep their seeded look — no fetch.
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS); if (DISCORD_USER_ID) {
loadSelfHosted();
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS);
}
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden && latest) updateTimes(); 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({});
})(); })();

View File

@ -1,6 +1,12 @@
(function friends() { (function friends() {
"use strict"; "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 = [ var FRIENDS = [
{ {
title: "Fiancée", 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"); var root = document.getElementById("friends-root");
if (!root) return; if (!root) return;
@ -65,324 +71,12 @@
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
} }
// ---- helpers (mirrors discord.js) ----------------------------------- var make = window.PresenceCard;
function esc(str) { if (typeof make !== "function") {
return String(str == null ? "" : str) console.error("friends.js: window.PresenceCard is missing — load /js/discord.js before /js/friends.js");
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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
? '<img class="fc-custom-emoji" src="' + eu + '" alt="" onerror="this.remove()">'
: (emoji && emoji.name ? '<span class="fc-custom-emoji-uni">' + esc(emoji.name) + "</span>" : "");
refs.custom.innerHTML = emojiHtml + (text ? '<span class="fc-custom-text">' + esc(text) + "</span>" : "");
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 = '<img class="fc-badge" src="' +
proxyImg("https://cdn.discordapp.com/badge-icons/" + esc(b.icon) + ".png") +
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
'" onerror="this.remove()">';
return b.link
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
: 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 = '<img class="fc-badge" src="' + proxyImg(url) +
'" alt="' + esc(b.description || b.id) + '" title="' + esc(b.description || b.id) +
'" onerror="this.remove()">';
return b.link
? '<a class="fc-badge-link" href="' + esc(b.link) + '" target="_blank" rel="noopener">' + img + "</a>"
: 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 ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
'<span class="fc-tag-text">' + esc(clan.tag) + "</span>";
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 ? '<img class="fc-tag-badge" src="' + b + '" alt="" onerror="this.remove()">' : "") +
'<span class="fc-tag-text">' + esc(pg.tag) + "</span>";
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 =
'<div class="fc-banner"></div>' +
'<div class="fc-main">' +
'<span class="fc-avatar">' +
'<img class="fc-pfp"' + (m.img ? ' src="' + esc(m.img) + '"' : '') + ' alt="' + esc(m.name) + '" loading="eager">' +
'<img class="fc-deco" alt="" aria-hidden="true" hidden>' +
'<span class="fc-status" title="not connected to Lanyard"></span>' +
'</span>' +
'<span class="fc-id">' +
'<span class="fc-name-row">' +
'<' + nameTag + ' class="fc-name' + (m.tier ? " " + m.tier : "") + '"' + nameAttrs + '>' + esc(m.name) + '</' + nameTag + '>' +
'<span class="fc-tag" hidden></span>' +
'</span>' +
'<span class="fc-user"></span>' +
'<span class="fc-custom" hidden></span>' +
'<span class="fc-badges"></span>' +
'</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"),
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
});
} }
// ---- render --------------------------------------------------------- // ---- render ---------------------------------------------------------
var liveMembers = []; // {m, refs} for those with an id, for polling
FRIENDS.forEach(function (group) { FRIENDS.forEach(function (group) {
var section = document.createElement("section"); var section = document.createElement("section");
section.className = "section"; section.className = "section";
@ -404,14 +98,26 @@
grid.className = "friend-grid"; grid.className = "friend-grid";
group.members.forEach(function (m) { group.members.forEach(function (m) {
var refs = buildCard(m); // placeholder slot — the factory replaces it with the finished card
grid.appendChild(refs.el); var slot = document.createElement("div");
// poll anyone with an ID — even "dead" alts can be semi-alive. Accounts grid.appendChild(slot);
// that 404 everywhere are dropped automatically by the give-up logic.
if (m.discordId) { if (typeof make === "function") {
var entry = { m: m, refs: refs, misses: 0, stop: false }; make({
liveMembers.push(entry); mount: slot,
pollEntry(entry); 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(); scrollToHash();
window.addEventListener("hashchange", 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);
}
})(); })();

View File

@ -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) { function fromSpotify(s) {
return { return {
song: s.song, artist: s.artist, album: s.album, song: s.song, artist: s.artist, album: s.album,
@ -405,28 +412,23 @@
showIdle(); showIdle();
} }
} }
function connectLanyard() { function pollPresence() {
try { ws = new WebSocket("wss://api.lanyard.rest/socket"); } if (document.hidden) return; // don't poll a backgrounded tab
catch (e) { return; } fetch(SELF_BASE + DISCORD_ID, { cache: "no-store" })
ws.onmessage = (ev) => { .then((r) => (r.ok ? r.json() : null))
let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } .then((j) => {
if (msg.op === 1) { if (!j || !j.success || !j.data) return;
const interval = (msg.d && msg.d.heartbeat_interval) || 30000; onPresence(j.data.presence || null); // presence is null when offline
if (heartbeat) clearInterval(heartbeat); })
heartbeat = setInterval(() => { .catch(() => { /* network blip — keep last state, retry next poll */ });
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 3 })); }
}, interval); function startPresence() {
ws.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } })); pollPresence();
} else if (msg.op === 0 && (msg.t === "INIT_STATE" || msg.t === "PRESENCE_UPDATE")) { presenceTimer = setInterval(pollPresence, PRESENCE_POLL_MS);
onPresence(msg.d); // refresh the moment the tab comes back into focus
} document.addEventListener("visibilitychange", () => {
}; if (!document.hidden) pollPresence();
ws.onclose = () => { });
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
retry = Math.min(retry + 1, 6);
setTimeout(connectLanyard, 1000 * retry);
};
ws.onerror = () => { if (ws) ws.close(); };
} }
// ======================================================================= // =======================================================================
@ -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() { async function loadTop() {
if (!LFM_OK || !topBox) { if (topBox) topBox.hidden = true; return; } if (!LFM_OK || !topBox) { if (topBox) topBox.hidden = true; return; }
try { try {
@ -525,9 +555,27 @@
'<ol class="top-chips">' + arr.map((a, i) => '<ol class="top-chips">' + arr.map((a, i) =>
'<li class="top-chip"><a href="' + esc(a.url) + '" target="_blank" rel="noopener">' + '<li class="top-chip"><a href="' + esc(a.url) + '" target="_blank" rel="noopener">' +
'<span class="top-rank">' + (i + 1) + "</span>" + '<span class="top-rank">' + (i + 1) + "</span>" +
'<span class="top-name">' + esc(a.name) + "</span>" + '<span class="top-art top-art-blank" aria-hidden="true">♪</span>' +
'<span class="top-plays">' + esc(a.playcount) + " plays</span>" + '<span class="top-text">' +
'<span class="top-name">' + esc(a.name) + "</span>" +
'<span class="top-plays">' + esc(a.playcount) + " plays</span>" +
"</span>" +
"</a></li>").join("") + "</ol>"; "</a></li>").join("") + "</ol>";
// 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; } } catch (e) { topBox.hidden = true; }
} }
@ -535,8 +583,8 @@
// boot // boot
// ======================================================================= // =======================================================================
paintHero(); paintHero();
showIdle(); // headline + lyrics before the socket warms up showIdle(); // headline + lyrics before the first poll lands
connectLanyard(); // takes over the hero the moment a presence arrives startPresence(); // takes over the hero whenever a live Spotify track is found
loadRecent(); loadRecent();
loadTop(); loadTop();
requestAnimationFrame(tick); requestAnimationFrame(tick);

View File

@ -2,42 +2,14 @@
* terminal.js the homepage's interactive terminal. * terminal.js the homepage's interactive terminal.
* *
* Flow: a short boot log streams in, the side chrome fades in alongside * 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 * it, then the banner + a pinned prompt appear. You type a command and
* social's name) and the output is appended to the scrollback BELOW the * the output is appended to the scrollback BELOW the input the input
* input the input itself never moves. * itself never moves.
* ===================================================================== */ * ===================================================================== */
(function terminal() { (function terminal() {
const root = document.getElementById("terminal"); const root = document.getElementById("terminal");
if (!root) return; 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 '<img class="t-social-ic" src="/assets/socials/' + (SOCIAL_ICON[key] || "site") + '.svg" alt="">';
}
// arch.ascii (hyfetch format) is fetched once at startup for `hyfetch`. // arch.ascii (hyfetch format) is fetched once at startup for `hyfetch`.
let archLines = null; let archLines = null;
function loadArt() { function loadArt() {
@ -83,7 +55,7 @@
'<pre class="t-boot" id="t-boot" aria-hidden="true"></pre>' + '<pre class="t-boot" id="t-boot" aria-hidden="true"></pre>' +
'<div class="t-main" id="t-main" hidden>' + '<div class="t-main" id="t-main" hidden>' +
'<pre class="t-banner">' + esc(BANNER) + "</pre>" + '<pre class="t-banner">' + esc(BANNER) + "</pre>" +
'<div class="t-greet">Type <b>help</b> for commands, or <b>socials</b> to browse.</div>' + '<div class="t-greet">Type <b>help</b> for commands.</div>' +
'<div class="t-inputline">' + '<div class="t-inputline">' +
'<span class="t-prompt">arch@arch<span class="t-path">:[~]$</span></span>' + '<span class="t-prompt">arch@arch<span class="t-path">:[~]$</span></span>' +
'<input class="t-input" id="t-input" type="text" aria-label="Terminal command input" autocomplete="off" autocapitalize="off" spellcheck="false">' + '<input class="t-input" id="t-input" type="text" aria-label="Terminal command input" autocomplete="off" autocapitalize="off" spellcheck="false">' +
@ -110,15 +82,12 @@
const rows = [ const rows = [
["help", "show this list"], ["help", "show this list"],
["code", "Shows the website source code"], ["code", "Shows the website source code"],
["socials", "list all socials"],
["<social>", "show a social & ask to open it (append -open to do directly)"],
["system", "open my system website (append a person's name to open their page)"], ["system", "open my system website (append a person's name to open their page)"],
["about", "a little about me"], ["about", "a little about me"],
["hyfetch", "system info, with flair"] ["hyfetch", "system info, with flair"]
]; ];
let out = "Available commands:\n"; let out = "Available commands:\n";
out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n"); out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n");
out += "\n\nTip: type a social's name (try 'socials') to open it.";
return { text: out }; return { text: out };
}, },
ls() { return COMMANDS.help(); }, ls() { return COMMANDS.help(); },
@ -151,14 +120,6 @@
return { text: "Failed to contact the server." }; return { text: "Failed to contact the server." };
} }
}, },
socials() {
const items = Object.keys(SOCIALS)
.map((k) => '<span class="t-ls-item">' + esc(k) + "</span>").join("");
return {
html: '<div class="t-ls">' + items + "</div>" +
'<div class="t-dim t-ls-foot">type one to view it, or <b>&lt;name&gt; -open</b> to open</div>'
};
},
about() { about() {
return { return {
text: text:
@ -214,7 +175,6 @@
const history = []; const history = [];
let histIdx = -1; let histIdx = -1;
let pendingSocial = null;
function showResult(result) { function showResult(result) {
output.innerHTML = ""; output.innerHTML = "";
@ -242,70 +202,19 @@
function run(raw) { function run(raw) {
const cmd = raw.trim(); const cmd = raw.trim();
output.innerHTML = ""; output.innerHTML = "";
if (!cmd) { pendingSocial = null; return; } if (!cmd) return;
history.push(cmd); histIdx = history.length; history.push(cmd); histIdx = history.length;
const parts = cmd.split(/\s+/); const parts = cmd.split(/\s+/);
const name = parts[0].toLowerCase(); const name = parts[0].toLowerCase();
const flags = parts.slice(1).map((p) => p.toLowerCase());
const wantsOpen = flags.indexOf("-open") >= 0 || flags.indexOf("--open") >= 0 || flags.indexOf("-o") >= 0;
if (pendingSocial) {
if (["y", "yes", "open", "o"].indexOf(name) >= 0) { openSocial(pendingSocial); return; }
if (["n", "no"].indexOf(name) >= 0) { pendingSocial = null; showResult({ text: "okay, leaving it closed." }); return; }
pendingSocial = null;
}
const socialKey = resolveSocial(name === "open" ? flags[0] : name);
if (socialKey) {
if (wantsOpen || name === "open") openSocial(socialKey);
else promptSocial(socialKey);
return;
}
if (COMMANDS[name]) { runCommand(COMMANDS[name], parts.slice(1)); return; } if (COMMANDS[name]) { runCommand(COMMANDS[name], parts.slice(1)); return; }
showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list, or 'socials' to browse.", error: true }); showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list.", error: true });
}
function promptSocial(key) {
const s = SOCIALS[key];
pendingSocial = key;
showResult({
html:
'<div class="t-social-card">' +
'<div class="t-sc-head">' + iconImg(key) +
'<span><b class="t-accent">' + esc(s.label) + "</b> " +
'<span class="t-dim">' + esc(s.sub) + "</span></span></div>" +
'<a class="t-sc-url" href="' + esc(s.url) + '"' +
(s.url.startsWith("mailto:") ? "" : ' target="_blank" rel="noopener"') + ">" + esc(s.url) + "</a>" +
'<div class="t-sc-ask t-dim">open it? type <b>y</b> · or run <b>' + esc(key) + " -open</b> · <b>n</b> to cancel</div>" +
"</div>"
});
}
function resolveSocial(key) {
if (!key) return null;
if (SOCIALS[key]) return key;
if (ALIASES[key]) return ALIASES[key];
return null;
}
function openSocial(key) {
pendingSocial = null;
const s = SOCIALS[key];
showResult({
html: '<a class="t-social-open" href="' + esc(s.url) + '"' +
(s.url.startsWith("mailto:") ? "" : ' target="_blank" rel="noopener"') + ">" +
iconImg(key) + "opening <b class=\"t-accent\">" + esc(s.label) + "</b> " +
'<span class="t-dim">' + esc(s.url) + "</span> …</a>"
});
if (s.url.startsWith("mailto:")) { window.location.href = s.url; }
else { window.open(s.url, "_blank", "noopener"); }
} }
// ---- tab-complete + history -------------------------------------------- // ---- tab-complete + history --------------------------------------------
const COMPLETIONS = Object.keys(COMMANDS).concat(["open", "socials"], Object.keys(SOCIALS), Object.keys(ALIASES)); const COMPLETIONS = Object.keys(COMMANDS);
function complete(prefix) { function complete(prefix) {
if (!prefix) return null; if (!prefix) return null;
const hits = COMPLETIONS.filter((c) => c.indexOf(prefix) === 0); const hits = COMPLETIONS.filter((c) => c.indexOf(prefix) === 0);

View File

@ -5,7 +5,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Warm up the origins this page's JS fetches on load (Last.fm now-playing + lyrics) --> <!-- Warm up the origins this page's JS fetches on load (presence + now-playing + lyrics) -->
<link rel="preconnect" href="https://restful.doughmination.uk" crossorigin>
<link rel="dns-prefetch" href="https://restful.doughmination.uk">
<link rel="preconnect" href="https://i.scdn.co">
<link rel="dns-prefetch" href="https://i.scdn.co">
<link rel="preconnect" href="https://ws.audioscrobbler.com" crossorigin> <link rel="preconnect" href="https://ws.audioscrobbler.com" crossorigin>
<link rel="dns-prefetch" href="https://ws.audioscrobbler.com"> <link rel="dns-prefetch" href="https://ws.audioscrobbler.com">
<link rel="preconnect" href="https://lyrics.lanyard.cafe" crossorigin> <link rel="preconnect" href="https://lyrics.lanyard.cafe" crossorigin>