Add headmate fronting thing

This commit is contained in:
Clove 2026-06-17 09:46:56 +01:00
parent 39c692ece1
commit c022564089
3 changed files with 204 additions and 0 deletions

View File

@ -3630,3 +3630,110 @@ body:has(.presence-stage) {
#lc-embed { left: 0.5rem !important; bottom: 0.5rem !important; } #lc-embed { left: 0.5rem !important; bottom: 0.5rem !important; }
#lc-embed > section > div { min-width: 0 !important; } #lc-embed > section > div { min-width: 0 !important; }
} }
/* =====================================================================
* fronting.js "currently fronting" box (sits below the terminal)
* ===================================================================== */
.fronting-card {
position: relative;
z-index: 1;
width: 100%;
margin: 0.9rem auto 0;
padding: 0.85rem 1rem;
background: var(--mantle);
border: 1px solid var(--surface-1);
border-radius: 14px;
box-shadow: 0 18px 50px -22px rgba(0, 0, 0, 0.6);
}
.fronting-card[hidden] { display: none; }
.fr-head {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.7rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--subtext-0);
}
.fr-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.55);
animation: fr-pulse 2.4s ease-out infinite;
}
@keyframes fr-pulse {
0% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(166, 227, 161, 0); }
100% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0); }
}
@media (prefers-reduced-motion: reduce) {
.fr-dot { animation: none; }
}
.fr-members {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.fr-member {
--fr-accent: var(--pink);
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1 1 auto;
min-width: 0;
padding: 0.5rem 0.75rem 0.5rem 0.5rem;
border-radius: 12px;
background: var(--surface-0);
border: 1px solid var(--surface-1);
border-left: 3px solid var(--fr-accent);
color: var(--text);
}
.fr-av {
width: 38px;
height: 38px;
border-radius: 50%;
object-fit: cover;
flex: 0 0 auto;
border: 2px solid var(--fr-accent);
background: var(--crust);
}
.fr-av--empty { display: inline-block; }
.fr-meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.fr-name {
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fr-pronouns {
font-size: 0.74rem;
color: var(--subtext-0);
}
.fr-empty {
font-size: 0.82rem;
color: var(--subtext-0);
font-style: italic;
}

View File

@ -60,6 +60,7 @@
<h2 class="pronouns">(fae/faer)</h2> <h2 class="pronouns">(fae/faer)</h2>
</header> </header>
<div class="terminal" id="terminal"></div> <div class="terminal" id="terminal"></div>
<div id="fronting"></div>
</main> </main>
<div id="visitor-counter" aria-label="Visitor count"></div> <div id="visitor-counter" aria-label="Visitor count"></div>
@ -69,6 +70,7 @@
<script src="/js/flavors.js"></script> <script src="/js/flavors.js"></script>
<script src="/js/dev-mode.js"></script> <script src="/js/dev-mode.js"></script>
<script src="/js/terminal.js"></script> <script src="/js/terminal.js"></script>
<script src="/js/fronting.js"></script>
<script src="/js/site-switcher.js"></script> <script src="/js/site-switcher.js"></script>
<!-- lanyard.cafe keyring (webring) — styled to match the site in css/main.css (#lc-embed) --> <!-- lanyard.cafe keyring (webring) — styled to match the site in css/main.css (#lc-embed) -->
<script src="https://lanyard.cafe/api/embed.js" data-theme="dark"></script> <script src="https://lanyard.cafe/api/embed.js" data-theme="dark"></script>

95
js/fronting.js Normal file
View File

@ -0,0 +1,95 @@
/* =====================================================================
* fronting.js homepage "who's fronting" box.
*
* Polls the system's PluralKit-style API for the current fronter(s) and
* renders a small card. Each member links to their page on the system
* site. Refreshes every 30s so switches show without a reload.
* ===================================================================== */
(function fronting() {
"use strict";
const mount = document.getElementById("fronting");
if (!mount) return;
const API = "https://doughmination.co.uk/api/fronters";
const POLL_MS = 30000;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// member.color is a 6-char hex string (no leading #), may be null.
function colorHex(c) {
return /^[0-9a-fA-F]{6}$/.test(c || "") ? "#" + c : null;
}
// ---- build the shell ----------------------------------------------------
const card = document.createElement("section");
card.id = "fronting";
card.className = "fronting-card";
card.hidden = true;
card.setAttribute("aria-label", "Currently fronting");
card.innerHTML =
'<div class="fr-head">' +
'<span class="fr-dot" aria-hidden="true"></span>' +
'<span class="fr-label">Currently fronting</span>' +
'</div>' +
'<div class="fr-members"></div>';
mount.replaceWith(card);
const membersEl = card.querySelector(".fr-members");
function memberHtml(m) {
const name = m.display_name || m.name || "Unknown";
const accent = colorHex(m.color);
const av = m.avatar_url
? '<img class="fr-av" src="' + esc(m.avatar_url) + '" alt="" referrerpolicy="no-referrer" loading="lazy">'
: '<span class="fr-av fr-av--empty" aria-hidden="true"></span>';
const pronouns = m.pronouns
? '<span class="fr-pronouns">' + esc(m.pronouns) + '</span>' : "";
return '<div class="fr-member"' +
(accent ? ' style="--fr-accent: ' + accent + '"' : "") + '>' +
av +
'<span class="fr-meta">' +
'<span class="fr-name">' + esc(name) + '</span>' +
pronouns +
'</span>' +
'</div>';
}
function render(members) {
if (!Array.isArray(members) || !members.length) {
// No one registered as fronting — keep the box but say so.
membersEl.innerHTML = '<span class="fr-empty">no one is currently fronting</span>';
card.hidden = false;
return;
}
membersEl.innerHTML = members.map(memberHtml).join("");
card.hidden = false;
}
let failed = false;
function load() {
fetch(API, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j) throw new Error("bad response");
render(j.members || []);
failed = false;
})
.catch(function () {
// On first failure hide the box quietly; if it was already showing,
// leave the last-known fronters up rather than flashing an error.
if (!failed && card.hidden) card.hidden = true;
failed = true;
});
}
load();
const timer = setInterval(load, POLL_MS);
// Refresh immediately when the tab becomes visible again.
document.addEventListener("visibilitychange", function () {
if (!document.hidden) load();
});
window.addEventListener("beforeunload", function () { clearInterval(timer); });
})();