Files
bitvid/js/ui/adminBadge.js
T
thePR0M3TH3ANandClaude Opus 4.8 d091ce8bac Admin ring on comments/channel + pending-submissions dots on Admin tab & Submissions subtab
- adminBadge.js: add applyAdminRing(el, id) for surfaces that already place the
  star on a specific element and just need the ring.
- comments: ring + star on admin authors' avatars; round the comment avatar box
  so the ring reads as a circle.
- channel page: ring the inner rounded avatar box (star already on the wrap).
- Notification dots: red dot on the profile modal's Admin nav tab and on the
  Submissions subtab button when there are pending submissions, so admins know
  where to look. profileModalController.setSubmissionsUnreadIndicator toggles
  both (Admin nav dot gated on the tab being visible); driven from
  applyProfileNotificationDot off app._submissionsPending.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 14:59:09 -04:00

161 lines
5.3 KiB
JavaScript

// js/ui/adminBadge.js
//
// Shared "this profile belongs to a bitvid admin" badges. An admin is the super
// admin OR a current list editor (moderator). Two visual treatments, driven off
// one synchronous check (`isAdminActor`):
// • a small gold STAR in the corner opposite the red notification dot
// (`.admin-star`), and
// • a gold RING around the avatar (`.admin-ring`).
// Styling lives in the matching CSS rules.
//
// `isAdminActor` is synchronous, so editors added to the admin lists only show
// once those lists have loaded from relays; the super admin (known from config)
// always resolves immediately. Surfaces that persist across a session (e.g. the
// profile button) should re-decorate on accessControl.onEditorsChange so a
// late-loading editor list still lights up.
import { accessControl } from "../accessControl.js";
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
export const ADMIN_STAR_CLASS = "admin-star";
export const ADMIN_RING_CLASS = "admin-ring";
export const ADMIN_WRAP_CLASS = "admin-avatar-wrap";
/**
* Is the given actor (hex pubkey or npub) a bitvid admin?
* @param {string} pubkeyOrNpub
* @returns {boolean}
*/
export function isAdminActor(pubkeyOrNpub) {
const npub = safeEncodeNpub(pubkeyOrNpub);
if (!npub) {
return false;
}
try {
return Boolean(accessControl?.isAdminEditor?.(npub));
} catch (error) {
return false;
}
}
function findDirectStar(container) {
for (const child of Array.from(container.children || [])) {
if (child.classList?.contains(ADMIN_STAR_CLASS)) {
return child;
}
}
return null;
}
// Pure DOM: add/remove the star in `container` given an already-computed
// decision. `container` MUST be positioned + non-clipped.
function setAdminStar(container, show, { doc, label }) {
if (!container || typeof container.querySelector !== "function" || !doc) {
return false;
}
const existing = findDirectStar(container);
if (show) {
if (!existing) {
const star = doc.createElement("span");
star.className = ADMIN_STAR_CLASS;
star.setAttribute("aria-hidden", "true");
star.title = label;
star.textContent = "★";
container.appendChild(star);
}
} else if (existing) {
existing.remove();
}
return show;
}
/**
* Show or hide the admin star inside `container`. The container MUST be a
* position:relative, non-clipped element (the star is absolutely positioned in
* its corner) — for avatars that clip to a circle, pass a wrapper around the
* clipped element, not the clipped element itself.
*
* Idempotent. Returns whether a star is present after the call.
*
* @param {Element|null} container
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {boolean}
*/
export function applyAdminStar(
container,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
return setAdminStar(container, isAdminActor(pubkeyOrNpub), { doc, label });
}
/**
* Toggle just the gold ring on a circular element (for surfaces that already
* place the star on a specific element and only need the ring added). The
* element should be circular (border-radius) for the ring to read right.
* @param {Element|null} el
* @param {string} pubkeyOrNpub
* @returns {boolean} whether the ring is applied
*/
export function applyAdminRing(el, pubkeyOrNpub) {
if (!el || !el.classList) {
return false;
}
const isAdmin = isAdminActor(pubkeyOrNpub);
el.classList.toggle(ADMIN_RING_CLASS, isAdmin);
return isAdmin;
}
/**
* Decorate a circular avatar element with BOTH the admin ring and the corner
* star. `avatarEl` is the circular element (an <img> or a clip container). The
* ring is a box-shadow on `avatarEl` itself (not clipped by its own overflow);
* the star needs a non-clipped, positioned wrapper, so this ensures `avatarEl`
* is wrapped in a `.admin-avatar-wrap` span and hangs the star off that.
*
* Idempotent — reuses an existing wrapper. Works whether `avatarEl` is already
* attached to the DOM (wraps it in place) or freshly built and detached (wraps
* it and returns the wrapper for the caller to append).
*
* @param {Element|null} avatarEl
* @param {string} pubkeyOrNpub
* @param {{ doc?: Document, label?: string }} [options]
* @returns {Element|null} the wrapper node (append THIS, not the bare avatar)
*/
export function decorateAdminAvatar(
avatarEl,
pubkeyOrNpub,
{ doc = typeof document !== "undefined" ? document : null, label = "bitvid admin" } = {},
) {
if (!avatarEl || !avatarEl.classList || !doc) {
return avatarEl || null;
}
const isAdmin = isAdminActor(pubkeyOrNpub);
avatarEl.classList.toggle(ADMIN_RING_CLASS, isAdmin);
let wrap = avatarEl.parentElement;
const alreadyWrapped =
wrap && wrap.classList && wrap.classList.contains(ADMIN_WRAP_CLASS);
if (!alreadyWrapped) {
wrap = doc.createElement("span");
wrap.className = ADMIN_WRAP_CLASS;
if (avatarEl.parentElement) {
avatarEl.parentElement.insertBefore(wrap, avatarEl);
}
wrap.appendChild(avatarEl);
}
setAdminStar(wrap, isAdmin, { doc, label });
return wrap;
}
export default {
ADMIN_STAR_CLASS,
ADMIN_RING_CLASS,
ADMIN_WRAP_CLASS,
isAdminActor,
applyAdminStar,
applyAdminRing,
decorateAdminAvatar,
};