mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up)
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu, no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the missing Blocked-videos tool. - Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is super-admin-only — its tab button is hidden otherwise. selectAdminSubtab() shows one section at a time (ARIA + data-state), falling back to the default when a hidden tab is requested. - Blocked videos: view the per-event admin block list, unblock per row (confirm → removeFromEventBlacklist), and add by nevent/hex id. Rows show the video title+author when it's in the local cache, else the shortened id. Reuses the existing accessControl event-blacklist API + onEventBlacklistChange (live re-render) and refreshes the grids on change. No backend changes. - Row-building/render logic in js/ui/profileModal/blockedVideosSection.js (keeps ProfileAdminController under its size cap; unit-testable without a DOM). Spec correction: profile-modal-controller.test's admin assertion updated for the sub-tab layout (Moderators section now hidden until its tab is active; its tab button is shown to a super admin; selecting it reveals the section). Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback, render, unblock wiring); profile-modal-controller admin test updated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
e3139e8712
commit
cf4ce5c3b8
5 files changed
+652
-9
No files matched your search
@@ -1693,6 +1693,59 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="adminSubtabBar"
|
||||
class="flex flex-wrap gap-2 border-b border-border/60 pb-3"
|
||||
role="tablist"
|
||||
aria-label="Admin tools"
|
||||
>
|
||||
<button
|
||||
id="adminSubtabWhitelist"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-admin-subtab="whitelist"
|
||||
aria-controls="adminWhitelistSection"
|
||||
aria-selected="true"
|
||||
data-state="active"
|
||||
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
Whitelist
|
||||
</button>
|
||||
<button
|
||||
id="adminSubtabBlacklist"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-admin-subtab="blacklist"
|
||||
aria-controls="adminBlacklistSection"
|
||||
aria-selected="false"
|
||||
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
Blacklist
|
||||
</button>
|
||||
<button
|
||||
id="adminSubtabBlockedVideos"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-admin-subtab="blockedVideos"
|
||||
aria-controls="adminBlockedVideosSection"
|
||||
aria-selected="false"
|
||||
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
Blocked videos
|
||||
</button>
|
||||
<button
|
||||
id="adminSubtabModerators"
|
||||
type="button"
|
||||
role="tab"
|
||||
data-admin-subtab="moderators"
|
||||
aria-controls="adminModeratorsSection"
|
||||
aria-selected="false"
|
||||
class="btn-ghost focus-ring px-3 py-1.5 text-sm font-medium hidden"
|
||||
>
|
||||
Moderators
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1790,7 +1843,7 @@
|
||||
<ul id="adminWhitelistList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminBlacklistSection" class="space-y-4">
|
||||
<div id="adminBlacklistSection" class="space-y-4 hidden" aria-hidden="true">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-base font-semibold text-text">Blacklist</h4>
|
||||
@@ -1836,6 +1889,53 @@
|
||||
</div>
|
||||
<ul id="adminBlacklistList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminBlockedVideosSection" class="space-y-4 hidden" aria-hidden="true">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-base font-semibold text-text">Blocked videos</h4>
|
||||
<button
|
||||
id="adminBlockedVideosRefreshBtn"
|
||||
type="button"
|
||||
class="btn-ghost btn-icon"
|
||||
data-size="sm"
|
||||
aria-label="Refresh blocked videos"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted">
|
||||
Individual videos hidden everywhere on bitvid without blocking their creator. Block a video from its ⋯ menu, or paste an event id / nevent below.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-muted-strong" for="adminBlockedVideoInput">
|
||||
<span class="mb-2 block font-medium">Event id or nevent</span>
|
||||
<input
|
||||
id="adminBlockedVideoInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
placeholder="nevent1… or hex event id"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddBlockedVideoBtn"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
>
|
||||
Block video
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminBlockedVideosEmpty"
|
||||
class="card border border-dashed border-border/60 p-4 text-center text-sm text-muted"
|
||||
>
|
||||
No videos are individually blocked yet.
|
||||
</div>
|
||||
<ul id="adminBlockedVideosList" class="grid-stack" data-orientation="vertical"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
import { isDevMode } from "../../config.js";
|
||||
import { ProfileAdminRenderer } from "./ProfileAdminRenderer.js";
|
||||
import { getApplication } from "../../applicationContext.js";
|
||||
import {
|
||||
buildBlockedVideoRows,
|
||||
renderBlockedVideosList,
|
||||
} from "./blockedVideosSection.js";
|
||||
import { showConfirm } from "../confirmDialog.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Admin pane sub-tabs, in toolbar order. "moderators" is only shown to the
|
||||
// super admin (its tab button is hidden otherwise).
|
||||
const ADMIN_SUBTABS = ["whitelist", "blacklist", "blockedVideos", "moderators"];
|
||||
const DEFAULT_ADMIN_SUBTAB = "whitelist";
|
||||
|
||||
export class ProfileAdminController {
|
||||
constructor(mainController) {
|
||||
this.mainController = mainController;
|
||||
@@ -27,6 +38,21 @@ export class ProfileAdminController {
|
||||
this.addBlacklistButton = null;
|
||||
this.blacklistInput = null;
|
||||
this.adminBlacklistRefreshBtn = null;
|
||||
|
||||
// Blocked videos (per-event admin block list) sub-tab.
|
||||
this.blockedVideosSection = null;
|
||||
this.blockedVideosEmpty = null;
|
||||
this.blockedVideosList = null;
|
||||
this.addBlockedVideoButton = null;
|
||||
this.blockedVideoInput = null;
|
||||
this.blockedVideosRefreshBtn = null;
|
||||
|
||||
// Sub-tab toolbar.
|
||||
this.subtabBar = null;
|
||||
this.subtabButtons = {};
|
||||
this.subtabSections = {};
|
||||
this.activeSubtab = DEFAULT_ADMIN_SUBTAB;
|
||||
this.eventBlacklistUnsub = null;
|
||||
}
|
||||
|
||||
cacheDomReferences() {
|
||||
@@ -50,6 +76,27 @@ export class ProfileAdminController {
|
||||
this.addBlacklistButton = document.getElementById("adminAddBlacklistBtn") || null;
|
||||
this.blacklistInput = document.getElementById("adminBlacklistInput") || null;
|
||||
this.adminBlacklistRefreshBtn = document.getElementById("adminBlacklistRefreshBtn") || null;
|
||||
|
||||
this.blockedVideosSection = document.getElementById("adminBlockedVideosSection") || null;
|
||||
this.blockedVideosEmpty = document.getElementById("adminBlockedVideosEmpty") || null;
|
||||
this.blockedVideosList = document.getElementById("adminBlockedVideosList") || null;
|
||||
this.addBlockedVideoButton = document.getElementById("adminAddBlockedVideoBtn") || null;
|
||||
this.blockedVideoInput = document.getElementById("adminBlockedVideoInput") || null;
|
||||
this.blockedVideosRefreshBtn = document.getElementById("adminBlockedVideosRefreshBtn") || null;
|
||||
|
||||
this.subtabBar = document.getElementById("adminSubtabBar") || null;
|
||||
this.subtabButtons = {
|
||||
whitelist: document.getElementById("adminSubtabWhitelist") || null,
|
||||
blacklist: document.getElementById("adminSubtabBlacklist") || null,
|
||||
blockedVideos: document.getElementById("adminSubtabBlockedVideos") || null,
|
||||
moderators: document.getElementById("adminSubtabModerators") || null,
|
||||
};
|
||||
this.subtabSections = {
|
||||
whitelist: this.whitelistSection,
|
||||
blacklist: this.blacklistSection,
|
||||
blockedVideos: this.blockedVideosSection,
|
||||
moderators: this.moderatorSection,
|
||||
};
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
@@ -155,6 +202,87 @@ export class ProfileAdminController {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ensureAriaLabel(this.blockedVideosRefreshBtn, "Refresh blocked videos");
|
||||
|
||||
for (const name of ADMIN_SUBTABS) {
|
||||
const button = this.subtabButtons?.[name];
|
||||
if (button instanceof HTMLElement) {
|
||||
button.addEventListener("click", () => this.selectAdminSubtab(name));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.addBlockedVideoButton instanceof HTMLElement) {
|
||||
this.addBlockedVideoButton.addEventListener("click", () => {
|
||||
void this.handleAddBlockedVideo();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.blockedVideoInput instanceof HTMLElement) {
|
||||
this.blockedVideoInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void this.handleAddBlockedVideo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.blockedVideosRefreshBtn instanceof HTMLElement) {
|
||||
this.blockedVideosRefreshBtn.addEventListener("click", () => {
|
||||
const service = this.mainController.services.accessControl;
|
||||
if (!service || typeof service.refresh !== "function") {
|
||||
return;
|
||||
}
|
||||
void service
|
||||
.refresh()
|
||||
.then(() => this.populateBlockedVideos())
|
||||
.catch((error) => {
|
||||
devLogger.warn("[profileModal] Failed to refresh blocked videos:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Re-render the blocked-videos list whenever the published list changes
|
||||
// (e.g. an admin blocks a video from the ⋯ menu while this pane is open).
|
||||
const accessControl = this.mainController.services?.accessControl;
|
||||
if (accessControl && typeof accessControl.onEventBlacklistChange === "function") {
|
||||
if (typeof this.eventBlacklistUnsub === "function") {
|
||||
this.eventBlacklistUnsub();
|
||||
}
|
||||
this.eventBlacklistUnsub = accessControl.onEventBlacklistChange(() => {
|
||||
this.populateBlockedVideos();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show one admin sub-tab's section and hide the rest, syncing button state.
|
||||
// Falls back to the default tab when asked for a hidden one (e.g. Moderators
|
||||
// for a non-super-admin).
|
||||
selectAdminSubtab(name) {
|
||||
let target = ADMIN_SUBTABS.includes(name) ? name : DEFAULT_ADMIN_SUBTAB;
|
||||
const button = this.subtabButtons?.[target];
|
||||
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
|
||||
target = DEFAULT_ADMIN_SUBTAB;
|
||||
}
|
||||
this.activeSubtab = target;
|
||||
|
||||
for (const key of ADMIN_SUBTABS) {
|
||||
const isActive = key === target;
|
||||
const tabButton = this.subtabButtons?.[key];
|
||||
const section = this.subtabSections?.[key];
|
||||
if (tabButton instanceof HTMLElement) {
|
||||
tabButton.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
if (isActive) {
|
||||
tabButton.dataset.state = "active";
|
||||
} else {
|
||||
delete tabButton.dataset.state;
|
||||
}
|
||||
}
|
||||
if (section instanceof HTMLElement) {
|
||||
section.classList.toggle("hidden", !isActive);
|
||||
section.setAttribute("aria-hidden", (!isActive).toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
populateAdminLists() {
|
||||
@@ -228,6 +356,137 @@ export class ProfileAdminController {
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort lookup of a blocked video from the shared feed cache so rows can
|
||||
// show a title/author; returns null when the video isn't cached (id-only row).
|
||||
resolveBlockedVideo(id) {
|
||||
try {
|
||||
const app = getApplication();
|
||||
const service = app?.nostrService || null;
|
||||
if (service && typeof service.getVideoByEventId === "function") {
|
||||
const video = service.getVideoByEventId(id);
|
||||
if (video) return video;
|
||||
}
|
||||
const client = service?.nostrClient || null;
|
||||
if (client?.allEvents && typeof client.allEvents.get === "function") {
|
||||
return client.allEvents.get(id) || null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal — fall back to an id-only row.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
populateBlockedVideos() {
|
||||
if (!(this.blockedVideosList instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const actorNpub = this.mainController.services.getCurrentUserNpub();
|
||||
const accessControl = this.mainController.services.accessControl;
|
||||
if (!actorNpub || !accessControl?.canEditAdminLists?.(actorNpub)) {
|
||||
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, []);
|
||||
return;
|
||||
}
|
||||
const ids =
|
||||
typeof accessControl.getEventBlacklist === "function"
|
||||
? accessControl.getEventBlacklist()
|
||||
: [];
|
||||
const rows = buildBlockedVideoRows(ids, (id) => this.resolveBlockedVideo(id));
|
||||
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, rows, {
|
||||
onUnblock: (id, button) => this.handleRemoveBlockedVideo(id, button),
|
||||
formatAuthor: (hex) =>
|
||||
typeof hex === "string" && hex.length > 12
|
||||
? `by ${hex.slice(0, 10)}…`
|
||||
: "",
|
||||
});
|
||||
}
|
||||
|
||||
async handleAddBlockedVideo() {
|
||||
const input = this.blockedVideoInput || null;
|
||||
const rawValue = typeof input?.value === "string" ? input.value.trim() : "";
|
||||
if (!rawValue) {
|
||||
this.mainController.showError("Enter an event id or nevent to block.");
|
||||
return;
|
||||
}
|
||||
const actorNpub = this.ensureAdminActor();
|
||||
if (!actorNpub) {
|
||||
return;
|
||||
}
|
||||
const accessControl = this.mainController.services.accessControl;
|
||||
try {
|
||||
const result = await accessControl.addToEventBlacklist(actorNpub, rawValue);
|
||||
if (result?.ok) {
|
||||
if (input) input.value = "";
|
||||
this.mainController.showSuccess("Video added to the block list.");
|
||||
this.populateBlockedVideos();
|
||||
this.refreshGridsAfterEventBlacklistChange();
|
||||
} else {
|
||||
this.mainController.showError(
|
||||
result?.error === "forbidden"
|
||||
? "You do not have permission to block videos."
|
||||
: result?.error === "invalid event id"
|
||||
? "That doesn't look like a valid event id or nevent."
|
||||
: "Failed to block the video. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.error("[profileModal] Failed to add blocked video:", error);
|
||||
this.mainController.showError("Failed to block the video. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoveBlockedVideo(id, button) {
|
||||
const actorNpub = this.ensureAdminActor();
|
||||
if (!actorNpub) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await showConfirm(
|
||||
"Unblock this video? It will become visible across bitvid again.",
|
||||
{ title: "Unblock video", confirmLabel: "Unblock", cancelLabel: "Cancel" },
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
if (button instanceof HTMLElement) {
|
||||
button.disabled = true;
|
||||
}
|
||||
const accessControl = this.mainController.services.accessControl;
|
||||
try {
|
||||
const result = await accessControl.removeFromEventBlacklist(actorNpub, id);
|
||||
if (result?.ok) {
|
||||
this.mainController.showSuccess("Video unblocked.");
|
||||
this.populateBlockedVideos();
|
||||
this.refreshGridsAfterEventBlacklistChange();
|
||||
} else {
|
||||
if (button instanceof HTMLElement) button.disabled = false;
|
||||
this.mainController.showError(
|
||||
result?.error === "forbidden"
|
||||
? "You do not have permission to unblock videos."
|
||||
: "Failed to unblock the video. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (button instanceof HTMLElement) button.disabled = false;
|
||||
userLogger.error("[profileModal] Failed to remove blocked video:", error);
|
||||
this.mainController.showError("Failed to unblock the video. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
refreshGridsAfterEventBlacklistChange() {
|
||||
try {
|
||||
const app = getApplication();
|
||||
if (app && typeof app.refreshAllVideoGrids === "function") {
|
||||
void app
|
||||
.refreshAllVideoGrids({
|
||||
reason: "admin-event-blacklist-update",
|
||||
forceMainReload: true,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAdminPaneState() {
|
||||
const adminNav = this.mainController.navButtons.admin;
|
||||
const adminPane = this.mainController.panes.admin;
|
||||
@@ -297,14 +556,21 @@ export class ProfileAdminController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.moderatorSection instanceof HTMLElement) {
|
||||
this.moderatorSection.classList.toggle("hidden", !isSuperAdmin);
|
||||
this.moderatorSection.setAttribute(
|
||||
"aria-hidden",
|
||||
(!isSuperAdmin).toString(),
|
||||
);
|
||||
// Moderators is super-admin-only: hide its sub-tab button entirely for
|
||||
// everyone else (its section visibility is then governed by selectAdminSubtab).
|
||||
const moderatorsTab = this.subtabButtons?.moderators;
|
||||
if (moderatorsTab instanceof HTMLElement) {
|
||||
moderatorsTab.classList.toggle("hidden", !isSuperAdmin);
|
||||
if (!isSuperAdmin) {
|
||||
moderatorsTab.setAttribute("aria-selected", "false");
|
||||
}
|
||||
}
|
||||
|
||||
this.populateAdminLists();
|
||||
this.populateBlockedVideos();
|
||||
// Re-apply the current sub-tab so exactly one section is visible (and so a
|
||||
// now-hidden Moderators tab falls back to the default).
|
||||
this.selectAdminSubtab(this.activeSubtab);
|
||||
this.mainController.showStatus(null);
|
||||
this.renderer.setAdminLoading(this, false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Helpers for the admin pane's "Blocked videos" sub-tab (#25 follow-up): render
|
||||
// the per-event admin block list with an unblock control, and add-by-id. Kept
|
||||
// separate from ProfileAdminController so that controller stays under its size
|
||||
// cap and the row-building logic is unit-testable without a DOM.
|
||||
|
||||
// Shorten a 64-char hex event id (or any long token) for compact display.
|
||||
export function shortenEventId(id) {
|
||||
const value = typeof id === "string" ? id.trim() : "";
|
||||
if (value.length <= 18) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
}
|
||||
|
||||
// Pure: turn the raw blocked-id list into display rows, enriching with a video's
|
||||
// title/author when `resolveVideo(id)` can find it in the local cache (falls
|
||||
// back to the shortened id). De-dupes and drops blanks so the UI can't render a
|
||||
// ghost row for a malformed entry.
|
||||
export function buildBlockedVideoRows(ids, resolveVideo) {
|
||||
const list = Array.isArray(ids) ? ids : [];
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const raw of list) {
|
||||
const id = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
|
||||
let title = "";
|
||||
let author = "";
|
||||
if (typeof resolveVideo === "function") {
|
||||
try {
|
||||
const video = resolveVideo(id);
|
||||
if (video && typeof video === "object") {
|
||||
title = typeof video.title === "string" ? video.title.trim() : "";
|
||||
author = typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort enrichment; fall back to the id.
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
// Primary line: the video title when known, else the shortened id.
|
||||
label: title || shortenEventId(id),
|
||||
// Secondary line: the shortened id, only when the title is the primary.
|
||||
sublabel: title ? shortenEventId(id) : "",
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Thin DOM render for the blocked-videos <ul>. Mirrors the admin list card
|
||||
// styling. `onUnblock(id, button)` handles confirm + removal in the controller.
|
||||
export function renderBlockedVideosList(
|
||||
listEl,
|
||||
emptyEl,
|
||||
rows,
|
||||
{ onUnblock, formatAuthor } = {},
|
||||
) {
|
||||
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.textContent = "";
|
||||
|
||||
const setHidden = (element, hidden) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
element.classList.toggle("hidden", hidden);
|
||||
if (hidden) {
|
||||
element.setAttribute("hidden", "");
|
||||
} else {
|
||||
element.removeAttribute("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
setHidden(emptyEl, false);
|
||||
setHidden(listEl, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHidden(emptyEl, true);
|
||||
setHidden(listEl, false);
|
||||
|
||||
for (const row of rows) {
|
||||
const item = document.createElement("li");
|
||||
item.className =
|
||||
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
|
||||
item.dataset.eventId = row.id;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "min-w-0 space-y-1";
|
||||
|
||||
const label = document.createElement("p");
|
||||
label.className = "truncate font-medium text-text";
|
||||
label.textContent = row.label;
|
||||
label.title = row.id;
|
||||
info.appendChild(label);
|
||||
|
||||
const authorText =
|
||||
row.author && typeof formatAuthor === "function"
|
||||
? formatAuthor(row.author)
|
||||
: "";
|
||||
const secondaryText = [row.sublabel, authorText].filter(Boolean).join(" · ");
|
||||
if (secondaryText) {
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "truncate text-xs text-muted";
|
||||
sub.textContent = secondaryText;
|
||||
info.appendChild(sub);
|
||||
}
|
||||
|
||||
item.appendChild(info);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "btn-ghost focus-ring";
|
||||
button.dataset.variant = "danger";
|
||||
button.textContent = "Unblock";
|
||||
button.setAttribute("aria-label", `Unblock video ${row.label}`);
|
||||
button.addEventListener("click", () => {
|
||||
if (typeof onUnblock === "function") {
|
||||
onUnblock(row.id, button);
|
||||
}
|
||||
});
|
||||
|
||||
item.appendChild(button);
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Admin pane "Blocked videos" sub-tab (#25 follow-up): the per-event admin block
|
||||
// list is now viewable/removable in the UI. These cover the row-building (pure)
|
||||
// and the list rendering + unblock wiring (jsdom).
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-blocked-videos-rows
|
||||
// given: "a raw blocked-id list and an optional cache resolver"
|
||||
// when: "buildBlockedVideoRows / renderBlockedVideosList run"
|
||||
// then: "rows dedupe + enrich, the list renders, and Unblock invokes the callback"
|
||||
// observable_outcomes:
|
||||
// - "id-only rows when the video isn't cached; title+id rows when it is"
|
||||
// - "empty list shows the empty state and hides the <ul>"
|
||||
// - "clicking Unblock calls onUnblock with the event id"
|
||||
// determinism_controls:
|
||||
// - "pure fn for row building; jsdom for render; no network/clock"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "snapshot rubber-stamping"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test, { beforeEach, afterEach } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
buildBlockedVideoRows,
|
||||
renderBlockedVideosList,
|
||||
shortenEventId,
|
||||
} from "../js/ui/profileModal/blockedVideosSection.js";
|
||||
|
||||
const HEX = "a".repeat(64);
|
||||
const HEX2 = "b".repeat(64);
|
||||
|
||||
test("buildBlockedVideoRows: id-only row when the video isn't cached", () => {
|
||||
const rows = buildBlockedVideoRows([HEX], () => null);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, HEX);
|
||||
assert.equal(rows[0].title, "");
|
||||
assert.equal(rows[0].label, shortenEventId(HEX), "label falls back to shortened id");
|
||||
assert.equal(rows[0].sublabel, "", "no sublabel without a title");
|
||||
});
|
||||
|
||||
test("buildBlockedVideoRows: enriches with title + author when cached", () => {
|
||||
const rows = buildBlockedVideoRows([HEX], (id) =>
|
||||
id === HEX ? { title: "My Clip", pubkey: HEX2 } : null,
|
||||
);
|
||||
assert.equal(rows[0].title, "My Clip");
|
||||
assert.equal(rows[0].label, "My Clip", "title becomes the primary label");
|
||||
assert.equal(rows[0].sublabel, shortenEventId(HEX), "id demoted to sublabel");
|
||||
assert.equal(rows[0].author, HEX2);
|
||||
});
|
||||
|
||||
test("buildBlockedVideoRows: dedupes, lowercases, and drops blanks", () => {
|
||||
const rows = buildBlockedVideoRows(
|
||||
[HEX, HEX.toUpperCase(), "", " ", HEX2],
|
||||
() => null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.id),
|
||||
[HEX, HEX2],
|
||||
"one row per distinct id, blanks removed",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildBlockedVideoRows: a throwing resolver degrades to an id-only row", () => {
|
||||
const rows = buildBlockedVideoRows([HEX], () => {
|
||||
throw new Error("cache miss");
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].label, shortenEventId(HEX));
|
||||
});
|
||||
|
||||
describe_render();
|
||||
|
||||
function describe_render() {
|
||||
let dom;
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete globalThis.document;
|
||||
delete globalThis.HTMLElement;
|
||||
});
|
||||
|
||||
const mount = () => {
|
||||
const list = dom.window.document.createElement("ul");
|
||||
const empty = dom.window.document.createElement("div");
|
||||
dom.window.document.body.append(list, empty);
|
||||
return { list, empty };
|
||||
};
|
||||
|
||||
test("renderBlockedVideosList: empty shows the empty state and hides the list", () => {
|
||||
const { list, empty } = mount();
|
||||
renderBlockedVideosList(list, empty, []);
|
||||
assert.equal(empty.classList.contains("hidden"), false, "empty state visible");
|
||||
assert.equal(list.classList.contains("hidden"), true, "list hidden");
|
||||
assert.equal(list.children.length, 0);
|
||||
});
|
||||
|
||||
test("renderBlockedVideosList: renders one row per blocked video", () => {
|
||||
const { list, empty } = mount();
|
||||
const rows = buildBlockedVideoRows([HEX, HEX2], () => null);
|
||||
renderBlockedVideosList(list, empty, rows, { onUnblock: () => {} });
|
||||
assert.equal(empty.classList.contains("hidden"), true, "empty hidden");
|
||||
assert.equal(list.children.length, 2, "two rows rendered");
|
||||
assert.equal(list.children[0].dataset.eventId, HEX);
|
||||
});
|
||||
|
||||
test("renderBlockedVideosList: clicking Unblock calls onUnblock with the id", () => {
|
||||
const { list, empty } = mount();
|
||||
const rows = buildBlockedVideoRows([HEX], () => null);
|
||||
const calls = [];
|
||||
renderBlockedVideosList(list, empty, rows, {
|
||||
onUnblock: (id) => calls.push(id),
|
||||
});
|
||||
const button = list.querySelector("button");
|
||||
assert.ok(button, "row has an Unblock button");
|
||||
button.click();
|
||||
assert.deepEqual(calls, [HEX], "onUnblock invoked with the blocked id");
|
||||
});
|
||||
}
|
||||
@@ -1158,9 +1158,26 @@ test('admin mutations invoke accessControl stubs and update admin DOM', async ()
|
||||
|
||||
assert.ok(controller.navButtons.admin);
|
||||
assert.equal(controller.navButtons.admin.classList.contains('hidden'), false);
|
||||
assert.equal(controller.moderatorSection.classList.contains('hidden'), false);
|
||||
// The admin pane is now organized into sub-tabs: Whitelist is the default tab
|
||||
// (visible), while Moderators/Blacklist/Blocked-videos sections are hidden
|
||||
// until their tab is selected. The Moderators *tab button* is shown to a
|
||||
// super admin. (Previously every section was stacked and visible at once.)
|
||||
const admin = controller.adminController;
|
||||
assert.equal(admin.whitelistSection.classList.contains('hidden'), false);
|
||||
assert.equal(admin.moderatorSection.classList.contains('hidden'), true);
|
||||
assert.equal(
|
||||
admin.subtabButtons.moderators.classList.contains('hidden'),
|
||||
false,
|
||||
'moderators sub-tab is available to a super admin',
|
||||
);
|
||||
// Content still populates behind the inactive tab.
|
||||
assert.equal(controller.adminModeratorList.querySelectorAll('li').length, 1);
|
||||
assert.equal(controller.adminModeratorList.hasAttribute('hidden'), false);
|
||||
// Selecting the Moderators sub-tab reveals its section and hides Whitelist.
|
||||
admin.selectAdminSubtab('moderators');
|
||||
assert.equal(admin.moderatorSection.classList.contains('hidden'), false);
|
||||
assert.equal(admin.whitelistSection.classList.contains('hidden'), true);
|
||||
// Back to Whitelist for the remaining assertions.
|
||||
admin.selectAdminSubtab('whitelist');
|
||||
assert.equal(
|
||||
controller.whitelistList.querySelectorAll('button').length > 0,
|
||||
true,
|
||||
|
||||
Reference in new issue
Block a user