stupid.cat
This commit is contained in:
parent
ab5fd681b6
commit
465c1886fc
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
|
|
@ -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.
|
|
||||||
|
|
@ -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"}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
91
css/main.css
91
css/main.css
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
// placeholder cards (e.g. dead alts) keep their seeded look — no fetch.
|
||||||
|
if (DISCORD_USER_ID) {
|
||||||
loadSelfHosted();
|
loadSelfHosted();
|
||||||
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS);
|
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({});
|
||||||
})();
|
})();
|
||||||
362
js/friends.js
362
js/friends.js
|
|
@ -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, "&").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
|
|
||||||
? '<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);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
98
js/music.js
98
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) {
|
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);
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
function startPresence() {
|
||||||
ws.onclose = () => {
|
pollPresence();
|
||||||
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
|
presenceTimer = setInterval(pollPresence, PRESENCE_POLL_MS);
|
||||||
retry = Math.min(retry + 1, 6);
|
// refresh the moment the tab comes back into focus
|
||||||
setTimeout(connectLanyard, 1000 * retry);
|
document.addEventListener("visibilitychange", () => {
|
||||||
};
|
if (!document.hidden) pollPresence();
|
||||||
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-art top-art-blank" aria-hidden="true">♪</span>' +
|
||||||
|
'<span class="top-text">' +
|
||||||
'<span class="top-name">' + esc(a.name) + "</span>" +
|
'<span class="top-name">' + esc(a.name) + "</span>" +
|
||||||
'<span class="top-plays">' + esc(a.playcount) + " plays</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);
|
||||||
|
|
|
||||||
105
js/terminal.js
105
js/terminal.js
|
|
@ -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><name> -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue