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="preconnect" 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>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
|
|
@ -102,6 +106,8 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
91
css/main.css
91
css/main.css
|
|
@ -4080,3 +4080,94 @@ body.lightbox-open {
|
|||
.lightbox-caption[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
15. Cool-people friend cards — mini presence cards
|
||||
Each friend is a full presence card (built by discord.js's
|
||||
PresenceCard factory) but smaller than the big /discord one.
|
||||
The BASE .presence-card is already compact (≈280px); we just
|
||||
un-fix it from the corner so the cards tile in .friend-grid,
|
||||
and re-add the tier hearts / dead-alt treatment on pc-* markup.
|
||||
============================================================ */
|
||||
.presence-card.is-mini {
|
||||
position: static;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
z-index: auto;
|
||||
margin: 0;
|
||||
width: 300px; /* clearly smaller than the 680px /discord card */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* keep things tidy at the small size */
|
||||
.presence-card.is-mini .pc-banner { height: 84px; }
|
||||
.presence-card.is-mini .pc-bio {
|
||||
max-height: 6.5em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* the friend name can open a personal site */
|
||||
.presence-card.is-mini .pc-name--link { text-decoration: none; }
|
||||
.presence-card.is-mini .pc-name--link:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- tier hearts (ported from the old .fc-name prefixes) ---- */
|
||||
.presence-card.is-mini .pc-name::before { content: "🩵 "; }
|
||||
.presence-card.is-mini.tier-known .pc-name::before { content: "💛 "; }
|
||||
.presence-card.is-mini.tier-wife .pc-name::before { content: "🖤 "; }
|
||||
.presence-card.is-mini.tier-close .pc-name::before { content: "🤍 "; }
|
||||
.presence-card.is-mini.tier-active-alt .pc-name::before { content: "🎭 "; }
|
||||
.presence-card.is-mini.tier-dead-alt .pc-name::before { content: "💀 "; }
|
||||
/* gradient names clip text to transparent — keep the heart visible */
|
||||
.presence-card.is-mini .pc-name.is-gradient::before {
|
||||
-webkit-text-fill-color: initial;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---- dead alts: greyed, struck through, no live status ---- */
|
||||
.presence-card.is-mini.tier-dead-alt .pc-av-img { filter: grayscale(1) brightness(0.6); }
|
||||
.presence-card.is-mini.tier-dead-alt .pc-name {
|
||||
color: var(--overlay-1);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.presence-card.is-mini.tier-dead-alt .pc-status { display: none; }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
16. Top artists — artist avatar in each chip (Deezer images)
|
||||
============================================================ */
|
||||
.top-chip a { align-items: center; }
|
||||
|
||||
.top-art {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-0);
|
||||
color: var(--subtext-0);
|
||||
font-size: 0.95rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
/* connection brand logos (replaces the old text type label) */
|
||||
.pc-conn-ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex: none;
|
||||
}
|
||||
.presence-card.is-mini .pc-conn-ic { width: 13px; height: 13px; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
(function presence() {
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ---- who are we showing? ------------------------------------------------
|
||||
|
|
@ -18,9 +18,22 @@
|
|||
return null;
|
||||
}
|
||||
|
||||
const DISCORD_USER_ID = resolveUserId();
|
||||
const mount = document.getElementById("discord");
|
||||
if (!mount || !DISCORD_USER_ID) return;
|
||||
// Build one presence card. Used full-size on /discord, and as small clones
|
||||
// on /cool-people (opts.mini). Options:
|
||||
// userId Discord id to show (defaults to resolveUserId())
|
||||
// mount element to replace with the card (defaults to #discord)
|
||||
// mini true → adds .is-mini for smaller styling
|
||||
// tier / link friend tier class + optional website link on the name
|
||||
// pollMs presence refresh cadence (default 20s)
|
||||
// fallbackName / fallbackImg
|
||||
// shown immediately, and kept if the API has no data for them
|
||||
// (lets ID-less / dead alts still render a card)
|
||||
function createPresenceCard(opts) {
|
||||
opts = opts || {};
|
||||
const DISCORD_USER_ID = opts.userId || resolveUserId();
|
||||
const mount = opts.mount || document.getElementById("discord");
|
||||
if (!mount) return null;
|
||||
if (!DISCORD_USER_ID && !opts.fallbackName) return null;
|
||||
|
||||
// ---- theme: only on standalone api pages (homepage uses data-flavor) ----
|
||||
if (!document.documentElement.getAttribute("data-flavor")) {
|
||||
|
|
@ -33,8 +46,10 @@
|
|||
|
||||
// ---- build the card -----------------------------------------------------
|
||||
const card = document.createElement("div");
|
||||
card.id = "discord";
|
||||
card.className = "presence-card";
|
||||
// Only the single owner card claims id="discord" (core.js + the gold-cat
|
||||
// observer key off it). Mini friend cards must not duplicate the id.
|
||||
if (!opts.mini) card.id = "discord";
|
||||
card.className = "presence-card" + (opts.mini ? " is-mini" : "") + (opts.tier ? " tier-" + opts.tier : "");
|
||||
card.hidden = true;
|
||||
card.innerHTML =
|
||||
'<img class="pc-banner" alt="" referrerpolicy="no-referrer" hidden>' +
|
||||
|
|
@ -85,6 +100,28 @@
|
|||
const connectionsEl = card.querySelector(".pc-connections");
|
||||
const pronounsEl = card.querySelector(".pc-pronouns");
|
||||
|
||||
// ---- friend-card extras: name link + instant placeholder ----------------
|
||||
// Optional website link on the name (friends can have a personal site).
|
||||
if (opts.link) {
|
||||
nameEl.classList.add("pc-name--link");
|
||||
nameEl.style.cursor = "pointer";
|
||||
nameEl.setAttribute("role", "link");
|
||||
nameEl.setAttribute("tabindex", "0");
|
||||
const goLink = function () { window.open(opts.link, "_blank", "noopener"); };
|
||||
nameEl.addEventListener("click", goLink);
|
||||
nameEl.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goLink(); }
|
||||
});
|
||||
}
|
||||
// Seed a placeholder so the card shows instantly (and remains for ID-less or
|
||||
// offline dead alts the API can't fill). render() overwrites it on success.
|
||||
if (opts.fallbackName) {
|
||||
nameEl.textContent = opts.fallbackName;
|
||||
avImg.src = opts.fallbackImg || avatarUrl({ id: DISCORD_USER_ID });
|
||||
card.dataset.status = "offline";
|
||||
card.hidden = false;
|
||||
}
|
||||
|
||||
// ---- wishlist (revealed by the star) ------------------------------------
|
||||
// Items come straight from the Doughmination Restful API (j.data.wishlist):
|
||||
// each is a resolved Shop item { sku_id, type, name, static_image_url,
|
||||
|
|
@ -321,6 +358,25 @@
|
|||
domain: function (n) { return "https://" + n; },
|
||||
bluesky: function (n) { return "https://bsky.app/profile/" + n; }
|
||||
};
|
||||
// connection type -> brand SVG in /assets/socials (anything unmapped uses
|
||||
// the generic globe "site.svg")
|
||||
const CONNECTION_ICON = {
|
||||
twitter: "twitter",
|
||||
bluesky: "bluesky",
|
||||
mastodon: "mastodon",
|
||||
twitch: "twitch",
|
||||
youtube: "youtube",
|
||||
reddit: "reddit",
|
||||
spotify: "spotify",
|
||||
discord: "discord",
|
||||
linkedin: "linkedin",
|
||||
domain: "site"
|
||||
};
|
||||
function connIcon(type) {
|
||||
const file = CONNECTION_ICON[String(type || "").toLowerCase()] || "site";
|
||||
return '<img class="pc-conn-ic" src="/assets/socials/' + file + '.svg" alt="' +
|
||||
esc(type) + '" title="' + esc(type) + '" loading="lazy" onerror="this.remove()">';
|
||||
}
|
||||
function renderConnections(accounts) {
|
||||
if (!connectionsEl) return;
|
||||
const list = (accounts || []).filter(function (a) { return a && a.name; });
|
||||
|
|
@ -328,7 +384,7 @@
|
|||
connectionsEl.innerHTML = list.map(function (a) {
|
||||
const maker = CONNECTION_URLS[a.type];
|
||||
const url = maker ? maker(a.name, a.id) : null;
|
||||
const inner = '<span class="pc-conn-type">' + esc(a.type) + "</span>" +
|
||||
const inner = connIcon(a.type) +
|
||||
'<span class="pc-conn-name">' + esc(a.name) + "</span>" +
|
||||
(a.verified ? '<span class="pc-conn-check" title="Verified">✓</span>' : "");
|
||||
return url
|
||||
|
|
@ -395,7 +451,9 @@
|
|||
const rgb = near ? `${near.r}, ${near.g}, ${near.b}` : `${r}, ${g}, ${b}`;
|
||||
card.style.setProperty("--dc-accent", rgb);
|
||||
card.classList.add("has-accent");
|
||||
document.documentElement.style.setProperty("--accent-rgb", rgb);
|
||||
// Only the full-size card recolours the whole page; mini friend cards
|
||||
// keep their accent local so they don't fight over --accent-rgb.
|
||||
if (!opts.mini) document.documentElement.style.setProperty("--accent-rgb", rgb);
|
||||
} catch (e) { resetAccent(); }
|
||||
};
|
||||
img.onerror = resetAccent;
|
||||
|
|
@ -405,7 +463,7 @@
|
|||
lastArtUrl = null;
|
||||
card.classList.remove("has-accent");
|
||||
card.style.removeProperty("--dc-accent");
|
||||
document.documentElement.style.removeProperty("--accent-rgb");
|
||||
if (!opts.mini) document.documentElement.style.removeProperty("--accent-rgb");
|
||||
}
|
||||
|
||||
// ---- section (row) builders --------------------------------------------
|
||||
|
|
@ -647,7 +705,7 @@
|
|||
// Returns presence + full profile (incl. theme_colors + display_name_styles)
|
||||
// in a single call. Lanyard + dstn.to were removed.
|
||||
const SELF_BASE = "https://restful.doughmination.uk/v1/users/";
|
||||
const SELF_POLL_MS = 20000; // own-presence refresh cadence
|
||||
const SELF_POLL_MS = opts.pollMs || 20000; // presence refresh cadence
|
||||
let selfTimer = null;
|
||||
|
||||
// self-host shape -> the Lanyard-shaped object render() already understands
|
||||
|
|
@ -729,11 +787,24 @@
|
|||
if (!document.hidden) loadSelfHosted();
|
||||
}
|
||||
|
||||
// boot: poll the Doughmination Restful API (the only source now).
|
||||
// boot: poll the Doughmination Restful API (the only source now). ID-less
|
||||
// placeholder cards (e.g. dead alts) keep their seeded look — no fetch.
|
||||
if (DISCORD_USER_ID) {
|
||||
loadSelfHosted();
|
||||
selfTimer = setInterval(pollSelfHost, SELF_POLL_MS);
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && latest) updateTimes();
|
||||
});
|
||||
|
||||
return card;
|
||||
} // ---- end createPresenceCard ----
|
||||
|
||||
// Expose the factory so other pages (e.g. /cool-people) can build cards.
|
||||
window.PresenceCard = createPresenceCard;
|
||||
|
||||
// Auto-mount the standalone card whenever its #discord placeholder exists,
|
||||
// preserving the original /discord page behaviour exactly.
|
||||
if (document.getElementById("discord")) createPresenceCard({});
|
||||
})();
|
||||
362
js/friends.js
362
js/friends.js
|
|
@ -1,6 +1,12 @@
|
|||
(function friends() {
|
||||
"use strict";
|
||||
|
||||
// Each friend is rendered as a full — but smaller — presence card, built by
|
||||
// the shared factory in discord.js (window.PresenceCard). Cards pull live
|
||||
// presence (status, activity, badges, banner, bio, connections, wishlist…)
|
||||
// from the same Doughmination Restful API the /discord card uses.
|
||||
// NOTE: discord.js must be loaded BEFORE this file (see cool-people/index.html).
|
||||
|
||||
var FRIENDS = [
|
||||
{
|
||||
title: "Fiancée",
|
||||
|
|
@ -51,7 +57,7 @@
|
|||
}
|
||||
];
|
||||
|
||||
var REFRESH_MS = 60000; // re-poll live friends once a minute
|
||||
var FRIEND_POLL_MS = 60000; // re-poll each live friend once a minute
|
||||
|
||||
var root = document.getElementById("friends-root");
|
||||
if (!root) return;
|
||||
|
|
@ -65,324 +71,12 @@
|
|||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
// ---- helpers (mirrors discord.js) -----------------------------------
|
||||
function esc(str) {
|
||||
return String(str == null ? "" : str)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").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
|
||||
});
|
||||
var make = window.PresenceCard;
|
||||
if (typeof make !== "function") {
|
||||
console.error("friends.js: window.PresenceCard is missing — load /js/discord.js before /js/friends.js");
|
||||
}
|
||||
|
||||
// ---- render ---------------------------------------------------------
|
||||
var liveMembers = []; // {m, refs} for those with an id, for polling
|
||||
|
||||
FRIENDS.forEach(function (group) {
|
||||
var section = document.createElement("section");
|
||||
section.className = "section";
|
||||
|
|
@ -404,14 +98,26 @@
|
|||
grid.className = "friend-grid";
|
||||
|
||||
group.members.forEach(function (m) {
|
||||
var refs = buildCard(m);
|
||||
grid.appendChild(refs.el);
|
||||
// poll anyone with an ID — even "dead" alts can be semi-alive. Accounts
|
||||
// that 404 everywhere are dropped automatically by the give-up logic.
|
||||
if (m.discordId) {
|
||||
var entry = { m: m, refs: refs, misses: 0, stop: false };
|
||||
liveMembers.push(entry);
|
||||
pollEntry(entry);
|
||||
// placeholder slot — the factory replaces it with the finished card
|
||||
var slot = document.createElement("div");
|
||||
grid.appendChild(slot);
|
||||
|
||||
if (typeof make === "function") {
|
||||
make({
|
||||
mount: slot,
|
||||
userId: m.discordId || null, // null → static placeholder card (dead alts)
|
||||
mini: true, // smaller styling + keeps page accent local
|
||||
pollMs: FRIEND_POLL_MS,
|
||||
tier: m.tier || null,
|
||||
link: m.link || null,
|
||||
fallbackName: m.name, // shown instantly + kept if the API has no data
|
||||
fallbackImg: m.img || null
|
||||
});
|
||||
} else {
|
||||
// hard fallback: at least show the name if the factory didn't load
|
||||
slot.className = "presence-card is-mini" + (m.tier ? " tier-" + m.tier : "");
|
||||
slot.dataset.status = "offline";
|
||||
slot.textContent = m.name;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -428,12 +134,4 @@
|
|||
}
|
||||
scrollToHash();
|
||||
window.addEventListener("hashchange", scrollToHash);
|
||||
|
||||
// ---- poll live members ----------------------------------------------
|
||||
if (liveMembers.length) {
|
||||
setInterval(function () {
|
||||
if (document.hidden) return;
|
||||
liveMembers.forEach(function (x) { if (!x.stop) pollEntry(x); });
|
||||
}, REFRESH_MS);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
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) {
|
||||
return {
|
||||
song: s.song, artist: s.artist, album: s.album,
|
||||
|
|
@ -405,28 +412,23 @@
|
|||
showIdle();
|
||||
}
|
||||
}
|
||||
function connectLanyard() {
|
||||
try { ws = new WebSocket("wss://api.lanyard.rest/socket"); }
|
||||
catch (e) { return; }
|
||||
ws.onmessage = (ev) => {
|
||||
let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (msg.op === 1) {
|
||||
const interval = (msg.d && msg.d.heartbeat_interval) || 30000;
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
heartbeat = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 3 }));
|
||||
}, interval);
|
||||
ws.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
|
||||
} else if (msg.op === 0 && (msg.t === "INIT_STATE" || msg.t === "PRESENCE_UPDATE")) {
|
||||
onPresence(msg.d);
|
||||
function pollPresence() {
|
||||
if (document.hidden) return; // don't poll a backgrounded tab
|
||||
fetch(SELF_BASE + DISCORD_ID, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
if (!j || !j.success || !j.data) return;
|
||||
onPresence(j.data.presence || null); // presence is null when offline
|
||||
})
|
||||
.catch(() => { /* network blip — keep last state, retry next poll */ });
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
|
||||
retry = Math.min(retry + 1, 6);
|
||||
setTimeout(connectLanyard, 1000 * retry);
|
||||
};
|
||||
ws.onerror = () => { if (ws) ws.close(); };
|
||||
function startPresence() {
|
||||
pollPresence();
|
||||
presenceTimer = setInterval(pollPresence, PRESENCE_POLL_MS);
|
||||
// refresh the moment the tab comes back into focus
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) pollPresence();
|
||||
});
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
|
|
@ -514,6 +516,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Last.fm stopped serving artist images — every artist returns the same
|
||||
// placeholder star — so we pull them from Deezer. Deezer's REST API blocks
|
||||
// CORS, but its JSONP mode (output=jsonp) doesn't. Resolves to a picture URL
|
||||
// (or "" when there's no match / it fails).
|
||||
function deezerArtistImg(name) {
|
||||
return new Promise(function (resolve) {
|
||||
if (!name) { resolve(""); return; }
|
||||
var cb = "__dz_" + Math.random().toString(36).slice(2);
|
||||
var s = document.createElement("script");
|
||||
var done = false;
|
||||
function finish(url) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { delete window[cb]; s.remove(); } catch (e) { /* no-op */ }
|
||||
resolve(url || "");
|
||||
}
|
||||
window[cb] = function (data) {
|
||||
var a = data && data.data && data.data[0];
|
||||
finish(a ? (a.picture_medium || a.picture || "") : "");
|
||||
};
|
||||
s.onerror = function () { finish(""); };
|
||||
s.src = "https://api.deezer.com/search/artist?q=" + encodeURIComponent(name) +
|
||||
"&limit=1&output=jsonp&callback=" + cb;
|
||||
document.head.appendChild(s);
|
||||
setTimeout(function () { finish(""); }, 6000);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTop() {
|
||||
if (!LFM_OK || !topBox) { if (topBox) topBox.hidden = true; return; }
|
||||
try {
|
||||
|
|
@ -525,9 +555,27 @@
|
|||
'<ol class="top-chips">' + arr.map((a, i) =>
|
||||
'<li class="top-chip"><a href="' + esc(a.url) + '" target="_blank" rel="noopener">' +
|
||||
'<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-plays">' + esc(a.playcount) + " plays</span>" +
|
||||
"</span>" +
|
||||
"</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; }
|
||||
}
|
||||
|
||||
|
|
@ -535,8 +583,8 @@
|
|||
// boot
|
||||
// =======================================================================
|
||||
paintHero();
|
||||
showIdle(); // headline + lyrics before the socket warms up
|
||||
connectLanyard(); // takes over the hero the moment a presence arrives
|
||||
showIdle(); // headline + lyrics before the first poll lands
|
||||
startPresence(); // takes over the hero whenever a live Spotify track is found
|
||||
loadRecent();
|
||||
loadTop();
|
||||
requestAnimationFrame(tick);
|
||||
|
|
|
|||
105
js/terminal.js
105
js/terminal.js
|
|
@ -2,42 +2,14 @@
|
|||
* terminal.js — the homepage's interactive terminal.
|
||||
*
|
||||
* Flow: a short boot log streams in, the side chrome fades in alongside
|
||||
* it, then the banner + a pinned prompt appear. You type a command (or a
|
||||
* social's name) and the output is appended to the scrollback BELOW the
|
||||
* input — the input itself never moves.
|
||||
* it, then the banner + a pinned prompt appear. You type a command and
|
||||
* the output is appended to the scrollback BELOW the input — the input
|
||||
* itself never moves.
|
||||
* ===================================================================== */
|
||||
(function terminal() {
|
||||
const root = document.getElementById("terminal");
|
||||
if (!root) return;
|
||||
|
||||
// ---- socials (keyword -> destination) ----------------------------------
|
||||
const SOCIALS = {
|
||||
gitgay: { label: "Git.Gay", sub: "@doughmination", url: "https://git.gay/doughmination", aliases: ["git.gay", "gitea", "github", "git"] },
|
||||
twitter: { label: "Twitter", sub: "@DoughminCEO", url: "https://x.com/DoughminCEO", aliases: ["x"] },
|
||||
bluesky: { label: "Bluesky", sub: "@doughmination.win", url: "https://bsky.app/profile/doughmination.win", aliases: ["bsky"] },
|
||||
linkedin: { label: "LinkedIn", sub: "Clove Twilight", url: "https://www.linkedin.com/in/estrogen/" },
|
||||
spotify: { label: "Spotify", sub: "doughmination", url: "https://open.spotify.com/user/x060f5w4ftwv8zc8fi9662t70" },
|
||||
discord: { label: "Discord", sub: "Doughmination", url: "https://discord.gg/YtJayCYEw5" },
|
||||
twitch: { label: "Twitch", sub: "@doughminationgaming", url: "https://www.twitch.tv/doughminationgaming" },
|
||||
reddit: { label: "Reddit", sub: "u/XerinDotZero", url: "https://www.reddit.com/user/XerinDotZero/" },
|
||||
youtube: { label: "YouTube", sub: "@CloveTwiGaming", url: "https://www.youtube.com/@CloveTwiGaming", aliases: ["yt"] },
|
||||
mastodon: { label: "Mastodon", sub: "@doughmination@mastodon.social", url: "https://mastodon.social/@doughmination" },
|
||||
email: { label: "Email", sub: "admin@doughmination.win", url: "mailto:admin@doughmination.win", aliases: ["mail"] }
|
||||
};
|
||||
const ALIASES = {};
|
||||
Object.keys(SOCIALS).forEach((k) => {
|
||||
(SOCIALS[k].aliases || []).forEach((a) => { ALIASES[a] = k; });
|
||||
});
|
||||
// keyword -> svg filename in /assets/socials
|
||||
const SOCIAL_ICON = {
|
||||
github: "github", gitgay: "git-gay", twitter: "twitter", bluesky: "bluesky",
|
||||
linkedin: "linkedin", spotify: "spotify", discord: "discord", twitch: "twitch",
|
||||
reddit: "reddit", youtube: "youtube", mastodon: "mastodon", email: "email"
|
||||
};
|
||||
function iconImg(key) {
|
||||
return '<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`.
|
||||
let archLines = null;
|
||||
function loadArt() {
|
||||
|
|
@ -83,7 +55,7 @@
|
|||
'<pre class="t-boot" id="t-boot" aria-hidden="true"></pre>' +
|
||||
'<div class="t-main" id="t-main" hidden>' +
|
||||
'<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">' +
|
||||
'<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">' +
|
||||
|
|
@ -110,15 +82,12 @@
|
|||
const rows = [
|
||||
["help", "show this list"],
|
||||
["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)"],
|
||||
["about", "a little about me"],
|
||||
["hyfetch", "system info, with flair"]
|
||||
];
|
||||
let out = "Available commands:\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 };
|
||||
},
|
||||
ls() { return COMMANDS.help(); },
|
||||
|
|
@ -151,14 +120,6 @@
|
|||
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() {
|
||||
return {
|
||||
text:
|
||||
|
|
@ -214,7 +175,6 @@
|
|||
|
||||
const history = [];
|
||||
let histIdx = -1;
|
||||
let pendingSocial = null;
|
||||
|
||||
function showResult(result) {
|
||||
output.innerHTML = "";
|
||||
|
|
@ -242,70 +202,19 @@
|
|||
function run(raw) {
|
||||
const cmd = raw.trim();
|
||||
output.innerHTML = "";
|
||||
if (!cmd) { pendingSocial = null; return; }
|
||||
if (!cmd) return;
|
||||
history.push(cmd); histIdx = history.length;
|
||||
|
||||
const parts = cmd.split(/\s+/);
|
||||
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; }
|
||||
|
||||
showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list, or 'socials' to browse.", 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"); }
|
||||
showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list.", error: true });
|
||||
}
|
||||
|
||||
// ---- 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) {
|
||||
if (!prefix) return null;
|
||||
const hits = COMPLETIONS.filter((c) => c.indexOf(prefix) === 0);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
<meta charset="UTF-8">
|
||||
<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="dns-prefetch" href="https://ws.audioscrobbler.com">
|
||||
<link rel="preconnect" href="https://lyrics.lanyard.cafe" crossorigin>
|
||||
|
|
|
|||
Loading…
Reference in New Issue