mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
Adds a "My Videos" tab to the profile modal that lists every video the logged-in user has published — collapsed to one row per video in its current state (including tombstoned ones) — each with a note-side health pill: red "No playable source", grey "Deleted on Nostr", green "OK". Shows a summary line (N videos · M need attention · K deleted) and refreshes from relays on open. - New MyVideosController (lazy-populated via selectPane, like the other panes): fetchVideosByAuthors -> collapseUserVideos -> classifyVideoHealth -> render. Rows built with createElement/textContent (no innerHTML; user titles/URLs are not interpolated into markup). Status pills reuse the relay-pill token styles. - Sidebar nav button + pane markup in components/profile-modal.html. - Wired into profileModalController (cache refs, listeners, selectPane hook); the existing nav-button loop auto-binds the click. This is display-only; later steps add the hosted-URL HEAD probe (dead-url), row actions (edit/delete/revert/storage cleanup), and Phase 2 bucket reconciliation. Build + full lint green (no new file-size/innerHTML/token violations). Co-Authored-By: Claude Opus 4.8 <[email protected]>
234 lines
7.1 KiB
JavaScript
234 lines
7.1 KiB
JavaScript
import { devLogger } from "../../utils/logger.js";
|
|
import { nostrClient } from "../../nostrClientFacade.js";
|
|
import { collapseUserVideos } from "./myVideosData.js";
|
|
import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js";
|
|
|
|
// Maps a health severity to a status-pill style, mirroring
|
|
// ProfileRelayController.applyStatusPill so the look stays consistent.
|
|
const PILL_BASE =
|
|
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-semibold whitespace-nowrap";
|
|
const PILL_BY_SEVERITY = {
|
|
error: "bg-status-danger/15 text-status-danger",
|
|
warning: "bg-status-warning/15 text-status-warning",
|
|
info: "bg-surface-strong/40 text-muted",
|
|
ok: "bg-status-success/15 text-status-success",
|
|
};
|
|
|
|
/**
|
|
* "My Videos" management pane: lists the logged-in user's published videos (all
|
|
* versions collapsed to their current state, including tombstoned ones) with a
|
|
* health pill so issues like missing sources or orphaned storage are obvious.
|
|
*
|
|
* Phase 1 (this controller): note-side health + display. Later steps add the
|
|
* hosted-URL HEAD probe, per-row actions, and bucket reconciliation.
|
|
*/
|
|
export class MyVideosController {
|
|
constructor(mainController) {
|
|
this.mainController = mainController;
|
|
this.listEl = null;
|
|
this.summaryEl = null;
|
|
this.loadingEl = null;
|
|
this.emptyEl = null;
|
|
this.refreshBtn = null;
|
|
this.loading = false;
|
|
this.publicBaseUrl = "";
|
|
}
|
|
|
|
cacheDomReferences() {
|
|
this.listEl = document.getElementById("profileMyVideosList") || null;
|
|
this.summaryEl = document.getElementById("profileMyVideosSummary") || null;
|
|
this.loadingEl = document.getElementById("profileMyVideosLoading") || null;
|
|
this.emptyEl = document.getElementById("profileMyVideosEmpty") || null;
|
|
this.refreshBtn = document.getElementById("profileMyVideosRefreshBtn") || null;
|
|
}
|
|
|
|
registerEventListeners() {
|
|
if (this.refreshBtn instanceof HTMLElement) {
|
|
this.refreshBtn.addEventListener("click", () => {
|
|
void this.populate({ forceFetch: true });
|
|
});
|
|
}
|
|
}
|
|
|
|
getPubkey() {
|
|
return this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey(),
|
|
);
|
|
}
|
|
|
|
async resolvePublicBaseUrl(pubkey) {
|
|
const r2Service = this.mainController.services?.r2Service;
|
|
if (!r2Service || typeof r2Service.resolveConnection !== "function") {
|
|
return "";
|
|
}
|
|
try {
|
|
const npub = this.mainController.safeEncodeNpub?.(pubkey) || "";
|
|
if (!npub) {
|
|
return "";
|
|
}
|
|
const settings = await r2Service.resolveConnection(npub);
|
|
return (settings?.publicBaseUrl || settings?.baseDomain || "").trim();
|
|
} catch (err) {
|
|
devLogger.warn("[myVideos] Failed to resolve storage base URL:", err);
|
|
return "";
|
|
}
|
|
}
|
|
|
|
setLoading(on) {
|
|
if (this.loadingEl instanceof HTMLElement) {
|
|
this.loadingEl.classList.toggle("hidden", !on);
|
|
}
|
|
if (this.refreshBtn instanceof HTMLElement) {
|
|
this.refreshBtn.disabled = !!on;
|
|
}
|
|
}
|
|
|
|
async populate({ forceFetch = false } = {}) {
|
|
if (this.loading) {
|
|
return;
|
|
}
|
|
if (!(this.listEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const pubkey = this.getPubkey();
|
|
if (!pubkey) {
|
|
this.renderRows([]);
|
|
this.setSummary("");
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this.setLoading(true);
|
|
try {
|
|
this.publicBaseUrl = await this.resolvePublicBaseUrl(pubkey);
|
|
|
|
const nostrService = this.mainController.services?.nostrService;
|
|
if (forceFetch && nostrService?.fetchVideosByAuthors) {
|
|
try {
|
|
await nostrService.fetchVideosByAuthors([pubkey]);
|
|
} catch (err) {
|
|
devLogger.warn("[myVideos] fetchVideosByAuthors failed:", err);
|
|
}
|
|
}
|
|
|
|
const all =
|
|
nostrClient && nostrClient.allEvents
|
|
? Array.from(nostrClient.allEvents.values())
|
|
: [];
|
|
const rows = collapseUserVideos(all, pubkey);
|
|
this.renderRows(rows);
|
|
this.setSummary(this.buildSummary(rows));
|
|
} finally {
|
|
this.loading = false;
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
buildSummary(rows) {
|
|
if (!rows.length) {
|
|
return "";
|
|
}
|
|
const counts = { issues: 0, deleted: 0 };
|
|
for (const video of rows) {
|
|
const health = classifyVideoHealth(video, {
|
|
publicBaseUrl: this.publicBaseUrl,
|
|
});
|
|
if (health.severity === "error" || health.severity === "warning") {
|
|
counts.issues += 1;
|
|
}
|
|
if (health.status === "deleted") {
|
|
counts.deleted += 1;
|
|
}
|
|
}
|
|
const parts = [`${rows.length} video${rows.length === 1 ? "" : "s"}`];
|
|
if (counts.issues) {
|
|
parts.push(`${counts.issues} need${counts.issues === 1 ? "s" : ""} attention`);
|
|
}
|
|
if (counts.deleted) {
|
|
parts.push(`${counts.deleted} deleted`);
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
setSummary(text) {
|
|
if (this.summaryEl instanceof HTMLElement) {
|
|
this.summaryEl.textContent = text || "";
|
|
}
|
|
}
|
|
|
|
renderRows(rows) {
|
|
if (!(this.listEl instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
this.listEl.replaceChildren();
|
|
const hasRows = Array.isArray(rows) && rows.length > 0;
|
|
if (this.emptyEl instanceof HTMLElement) {
|
|
this.emptyEl.classList.toggle("hidden", hasRows);
|
|
}
|
|
if (!hasRows) {
|
|
return;
|
|
}
|
|
for (const video of rows) {
|
|
this.listEl.appendChild(this.buildRow(video));
|
|
}
|
|
}
|
|
|
|
buildRow(video) {
|
|
const health = classifyVideoHealth(video, {
|
|
publicBaseUrl: this.publicBaseUrl,
|
|
});
|
|
|
|
const li = document.createElement("li");
|
|
li.className =
|
|
"card flex items-center justify-between gap-3 p-3 border border-border/60";
|
|
li.dataset.health = health.status;
|
|
|
|
const info = document.createElement("div");
|
|
info.className = "min-w-0 flex-1";
|
|
|
|
const titleRow = document.createElement("div");
|
|
titleRow.className = "flex items-center gap-2 min-w-0";
|
|
|
|
const title = document.createElement("span");
|
|
title.className = "truncate font-medium text-text";
|
|
title.textContent =
|
|
(typeof video.title === "string" && video.title.trim()) || "Untitled video";
|
|
|
|
const pill = document.createElement("span");
|
|
pill.className = `${PILL_BASE} ${PILL_BY_SEVERITY[health.severity] || PILL_BY_SEVERITY.info}`;
|
|
pill.textContent = health.label;
|
|
|
|
titleRow.appendChild(title);
|
|
titleRow.appendChild(pill);
|
|
|
|
const meta = document.createElement("p");
|
|
meta.className = "mt-1 text-2xs text-muted";
|
|
meta.textContent = this.describeSource(video);
|
|
|
|
info.appendChild(titleRow);
|
|
info.appendChild(meta);
|
|
li.appendChild(info);
|
|
return li;
|
|
}
|
|
|
|
describeSource(video) {
|
|
const parts = [];
|
|
if (typeof video.url === "string" && video.url.trim()) {
|
|
parts.push(
|
|
isUrlUnderBase(video.url, this.publicBaseUrl) ? "Hosted URL" : "External URL",
|
|
);
|
|
}
|
|
if (typeof video.magnet === "string" && video.magnet.trim()) {
|
|
parts.push("Torrent");
|
|
}
|
|
if (!parts.length) {
|
|
parts.push("No source");
|
|
}
|
|
const created = Number(video.created_at);
|
|
if (Number.isFinite(created) && created > 0) {
|
|
parts.push(new Date(created * 1000).toLocaleDateString());
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
}
|