mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
The create path — makes playlists actually usable end-to-end. - js/ui/playlistPicker.js: an "Add to playlist" dialog (modeled on confirmDialog) listing the signed-in user's playlists as checkboxes (checked when the video is already in them) + a "Create new playlist" input. On Save it diffs the checkboxes and publishes only changed playlists (add/remove the video) plus any new one, via the facade (active signer). Reads the current pubkey from nostrClient, so callers pass only the video coordinate. - videoMenuRenderers.js: "Add to playlist" item in the video ⋯ menu, gated by a new canManagePlaylists param. - moreMenuController.js: computes canManagePlaylists (FEATURE_PLAYLISTS && logged in), threads it into the panel context, and adds the add-to-playlist handler (resolves the video → coordinate via buildVideoAddressPointer, closes the popover, opens the picker; success/error routed to the app's toasts). - constants.js: FEATURE_PLAYLISTS flipped ON now that the core loop (create / add / remove / channel section / playlist view) works. Rollback: set false. Owner controls on the playlist view (rename / drag-reorder / hard-delete) are a follow-up; removing every video empties a playlist (drops from the listing). Syntax + lint + build clean; more-menu-controller tests 9/9. Co-Authored-By: Claude Opus 4.8 <[email protected]>
588 lines
15 KiB
JavaScript
588 lines
15 KiB
JavaScript
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
|
|
|
function resolveDocument(documentRef) {
|
|
if (documentRef && documentRef.nodeType === 9) {
|
|
return documentRef;
|
|
}
|
|
if (typeof document !== "undefined" && document?.nodeType === 9) {
|
|
return document;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) {
|
|
const el = doc.createElement(tagName);
|
|
classNames
|
|
.filter((name) => typeof name === "string" && name.trim())
|
|
.forEach((name) => el.classList.add(name));
|
|
Object.entries(attrs).forEach(([key, value]) => {
|
|
if (value === undefined || value === null || value === "") {
|
|
return;
|
|
}
|
|
el.setAttribute(key, value);
|
|
});
|
|
if (typeof textContent === "string") {
|
|
el.textContent = textContent;
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function normalizePointer(pointerInfo) {
|
|
if (!pointerInfo || typeof pointerInfo !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const pointer = Array.isArray(pointerInfo.pointer)
|
|
? pointerInfo.pointer
|
|
: null;
|
|
if (!pointer || pointer.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const [type, value, relay] = pointer;
|
|
const normalized = {
|
|
key: typeof pointerInfo.key === "string" ? pointerInfo.key : "",
|
|
type: typeof type === "string" ? type : "",
|
|
value: typeof value === "string" ? value : "",
|
|
relay: typeof relay === "string" ? relay : "",
|
|
};
|
|
|
|
if (!normalized.type || !normalized.value) {
|
|
return null;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function resolveVideoMetadata(video) {
|
|
if (!video || typeof video !== "object") {
|
|
return {
|
|
id: "",
|
|
pubkey: "",
|
|
title: "",
|
|
description: "",
|
|
thumbnail: "",
|
|
isPrivate: false,
|
|
kind: null,
|
|
};
|
|
}
|
|
|
|
const normalized = {
|
|
id: typeof video.id === "string" ? video.id : "",
|
|
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
|
title: typeof video.title === "string" ? video.title : "",
|
|
description:
|
|
typeof video.description === "string" ? video.description : "",
|
|
thumbnail:
|
|
typeof video.thumbnail === "string" ? video.thumbnail : "",
|
|
isPrivate: video.isPrivate === true,
|
|
};
|
|
|
|
if (Number.isFinite(video.kind) && video.kind > 0) {
|
|
normalized.kind = Math.floor(video.kind);
|
|
} else {
|
|
normalized.kind = null;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function ensureMenuContainer(doc, panelClassNames = []) {
|
|
const panel = createElement(doc, "div", {
|
|
classNames: ["popover__panel", ...panelClassNames],
|
|
attrs: { role: "menu" },
|
|
});
|
|
|
|
const list = createElement(doc, "div", {
|
|
classNames: ["menu"],
|
|
attrs: { role: "none" },
|
|
});
|
|
|
|
panel.appendChild(list);
|
|
return { panel, list };
|
|
}
|
|
|
|
function appendMenuSeparator(doc, list) {
|
|
const separator = createElement(doc, "div", {
|
|
classNames: ["menu__separator"],
|
|
attrs: { role: "separator" },
|
|
});
|
|
list.appendChild(separator);
|
|
return separator;
|
|
}
|
|
|
|
function appendMenuHeading(doc, list, text) {
|
|
const heading = createElement(doc, "div", {
|
|
classNames: ["menu__heading"],
|
|
textContent: text,
|
|
});
|
|
list.appendChild(heading);
|
|
return heading;
|
|
}
|
|
|
|
function appendMenuAction(doc, list, {
|
|
text,
|
|
action,
|
|
dataset = {},
|
|
variant = null,
|
|
context = "",
|
|
}) {
|
|
const button = createElement(doc, "button", {
|
|
classNames: ["menu__item", "justify-start"],
|
|
textContent: text,
|
|
attrs: { type: "button", role: "menuitem" },
|
|
});
|
|
|
|
button.dataset.action = action;
|
|
if (variant) {
|
|
button.dataset.variant = variant;
|
|
}
|
|
if (context) {
|
|
button.dataset.context = context;
|
|
}
|
|
|
|
Object.entries(dataset).forEach(([key, value]) => {
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
button.dataset[key] = String(value);
|
|
});
|
|
|
|
list.appendChild(button);
|
|
return button;
|
|
}
|
|
|
|
export function createVideoMoreMenuPanel({
|
|
document: documentRef = null,
|
|
video = null,
|
|
pointerInfo = null,
|
|
playbackUrl = "",
|
|
playbackMagnet = "",
|
|
canManageBlacklist = false,
|
|
canManagePlaylists = false,
|
|
context = "card",
|
|
designSystem = null,
|
|
} = {}) {
|
|
const doc = resolveDocument(documentRef);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
normalizeDesignSystemContext(designSystem);
|
|
|
|
const metadata = resolveVideoMetadata(video);
|
|
const pointer = normalizePointer(pointerInfo);
|
|
|
|
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
|
|
panel.dataset.menu = "video-more";
|
|
panel.dataset.menuContext = context;
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Open channel",
|
|
action: "open-channel",
|
|
dataset: { author: metadata.pubkey, context },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Copy link",
|
|
action: "copy-link",
|
|
dataset: { eventId: metadata.id, context },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Popularity",
|
|
action: "view-stats",
|
|
dataset: { eventId: metadata.id, context },
|
|
});
|
|
|
|
if (canManagePlaylists) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Add to playlist",
|
|
action: "add-to-playlist",
|
|
dataset: { eventId: metadata.id, context },
|
|
});
|
|
}
|
|
|
|
const baseBoostDataset = {
|
|
eventId: metadata.id,
|
|
author: metadata.pubkey,
|
|
context,
|
|
};
|
|
|
|
if (pointer) {
|
|
baseBoostDataset.pointerType = pointer.type;
|
|
baseBoostDataset.pointerValue = pointer.value;
|
|
if (pointer.relay) {
|
|
baseBoostDataset.pointerRelay = pointer.relay;
|
|
}
|
|
if (pointer.key) {
|
|
baseBoostDataset.pointerKey = pointer.key;
|
|
}
|
|
}
|
|
|
|
if (Number.isFinite(metadata.kind) && metadata.kind > 0) {
|
|
baseBoostDataset.kind = String(metadata.kind);
|
|
}
|
|
|
|
appendMenuHeading(doc, list, "Boost on Nostr…");
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Repost (kind 6)",
|
|
action: "repost-event",
|
|
dataset: baseBoostDataset,
|
|
});
|
|
|
|
if (playbackUrl && metadata.isPrivate !== true) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Mirror (kind 1063)",
|
|
action: "mirror-video",
|
|
dataset: {
|
|
...baseBoostDataset,
|
|
url: playbackUrl,
|
|
magnet: playbackMagnet || "",
|
|
thumbnail: metadata.thumbnail,
|
|
description: metadata.description,
|
|
title: metadata.title,
|
|
isPrivate: metadata.isPrivate ? "true" : "false",
|
|
},
|
|
});
|
|
}
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Rebroadcast",
|
|
action: "ensure-presence",
|
|
dataset: {
|
|
...baseBoostDataset,
|
|
pubkey: metadata.pubkey,
|
|
},
|
|
});
|
|
|
|
appendMenuSeparator(doc, list);
|
|
|
|
if (pointer) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Remove from history",
|
|
action: "remove-history",
|
|
dataset: {
|
|
pointerKey: pointer.key,
|
|
pointerType: pointer.type,
|
|
pointerValue: pointer.value,
|
|
pointerRelay: pointer.relay,
|
|
reason: "remove-item",
|
|
},
|
|
});
|
|
}
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Mute creator",
|
|
action: "mute-author",
|
|
dataset: { author: metadata.pubkey, context },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Unmute creator",
|
|
action: "unmute-author",
|
|
dataset: { author: metadata.pubkey, context },
|
|
});
|
|
|
|
if (canManageBlacklist) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Blacklist creator",
|
|
action: "blacklist-author",
|
|
variant: "critical",
|
|
dataset: { author: metadata.pubkey },
|
|
});
|
|
appendMenuAction(doc, list, {
|
|
text: "Block this video",
|
|
action: "blacklist-event",
|
|
variant: "critical",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
}
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Block creator",
|
|
action: "block-author",
|
|
variant: "critical",
|
|
dataset: { author: metadata.pubkey },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Report",
|
|
action: "report",
|
|
dataset: {
|
|
eventId: metadata.id,
|
|
author: metadata.pubkey,
|
|
pointerRelay: pointer && pointer.relay ? pointer.relay : "",
|
|
},
|
|
});
|
|
|
|
appendMenuSeparator(doc, list);
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Event Details",
|
|
action: "event-details",
|
|
dataset: {
|
|
eventId: metadata.id,
|
|
context,
|
|
},
|
|
});
|
|
|
|
return panel;
|
|
}
|
|
|
|
export function createVideoShareMenuPanel({
|
|
document: documentRef = null,
|
|
video = null,
|
|
isLoggedIn = false,
|
|
hasSigner = false,
|
|
hasMagnet = false,
|
|
hasCdn = false,
|
|
// Optional builder for forced-source test deep links: fn('torrent'|'url') ->
|
|
// a `?v=<nevent>&playback=...` URL (or "" when unavailable). When provided,
|
|
// the menu offers "Copy WebTorrent URL" / "Copy CDN URL" items that copy a
|
|
// link which always plays via that source — handy for testing one path
|
|
// consistently. `clipboard` is injectable for tests.
|
|
buildForcedSourceUrl = null,
|
|
clipboard = null,
|
|
designSystem = null,
|
|
} = {}) {
|
|
const doc = resolveDocument(documentRef);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
normalizeDesignSystemContext(designSystem);
|
|
|
|
const metadata = resolveVideoMetadata(video);
|
|
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
|
|
panel.dataset.menu = "video-share";
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Copy URL",
|
|
action: "share",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
|
|
const nostrBtn = appendMenuAction(doc, list, {
|
|
text: "Share on Nostr",
|
|
action: "share-nostr",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
const canShareOnNostr = Boolean(isLoggedIn) && Boolean(hasSigner);
|
|
if (!canShareOnNostr) {
|
|
nostrBtn.disabled = true;
|
|
nostrBtn.classList.add("opacity-50", "cursor-not-allowed");
|
|
nostrBtn.title = isLoggedIn
|
|
? "Connect a Nostr signer to share"
|
|
: "Log in to share on Nostr";
|
|
}
|
|
|
|
const magnetBtn = appendMenuAction(doc, list, {
|
|
text: "Copy Magnet",
|
|
action: "copy-magnet",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
if (!hasMagnet) {
|
|
magnetBtn.disabled = true;
|
|
magnetBtn.classList.add("opacity-50", "cursor-not-allowed");
|
|
magnetBtn.title = "No magnet link available";
|
|
}
|
|
|
|
const cdnBtn = appendMenuAction(doc, list, {
|
|
text: "Copy CDN Link",
|
|
action: "copy-cdn",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
if (!hasCdn) {
|
|
cdnBtn.disabled = true;
|
|
cdnBtn.classList.add("opacity-50", "cursor-not-allowed");
|
|
cdnBtn.title = "No CDN link available";
|
|
}
|
|
|
|
// Forced-source test deep links. Self-contained: they copy a link via the
|
|
// injected builder + clipboard rather than routing through the shared menu
|
|
// dispatch, so they work regardless of how the share popover is wired.
|
|
if (typeof buildForcedSourceUrl === "function") {
|
|
const resolveClipboard = () =>
|
|
clipboard ||
|
|
(typeof navigator !== "undefined" ? navigator.clipboard : null);
|
|
|
|
const wireForcedCopy = (button, playback, available, unavailableTitle) => {
|
|
if (!available) {
|
|
button.disabled = true;
|
|
button.classList.add("opacity-50", "cursor-not-allowed");
|
|
button.title = unavailableTitle;
|
|
return;
|
|
}
|
|
const originalText = button.textContent;
|
|
button.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const url = buildForcedSourceUrl(playback) || "";
|
|
if (!url) {
|
|
return;
|
|
}
|
|
const clip = resolveClipboard();
|
|
try {
|
|
if (clip && typeof clip.writeText === "function") {
|
|
await clip.writeText(url);
|
|
}
|
|
button.textContent = "Copied!";
|
|
} catch (error) {
|
|
button.textContent = "Copy failed";
|
|
}
|
|
if (typeof setTimeout === "function") {
|
|
setTimeout(() => {
|
|
button.textContent = originalText;
|
|
}, 1200);
|
|
}
|
|
});
|
|
};
|
|
|
|
const torrentLinkBtn = appendMenuAction(doc, list, {
|
|
text: "Copy WebTorrent URL",
|
|
action: "copy-webtorrent-url",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
wireForcedCopy(
|
|
torrentLinkBtn,
|
|
"torrent",
|
|
hasMagnet,
|
|
"No WebTorrent source for this video",
|
|
);
|
|
|
|
const cdnLinkBtn = appendMenuAction(doc, list, {
|
|
text: "Copy CDN URL",
|
|
action: "copy-cdn-url",
|
|
dataset: { eventId: metadata.id },
|
|
});
|
|
wireForcedCopy(
|
|
cdnLinkBtn,
|
|
"url",
|
|
hasCdn,
|
|
"No CDN source for this video",
|
|
);
|
|
}
|
|
|
|
return panel;
|
|
}
|
|
|
|
export function createChannelProfileMenuPanel({
|
|
document: documentRef = null,
|
|
context = "channel-profile",
|
|
designSystem = null,
|
|
} = {}) {
|
|
const doc = resolveDocument(documentRef);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
|
|
normalizeDesignSystemContext(designSystem);
|
|
|
|
const normalizedContext =
|
|
typeof context === "string" && context ? context : "channel-profile";
|
|
|
|
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
|
|
panel.dataset.menu = "channel-profile";
|
|
panel.dataset.menuContext = normalizedContext;
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Copy npub",
|
|
action: "copy-npub",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Mute channel",
|
|
action: "mute-author",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Unmute channel",
|
|
action: "unmute-author",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Blacklist channel",
|
|
action: "blacklist-author",
|
|
variant: "critical",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Block channel",
|
|
action: "block-author",
|
|
variant: "critical",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Report",
|
|
action: "report",
|
|
dataset: { context: normalizedContext },
|
|
});
|
|
|
|
return panel;
|
|
}
|
|
|
|
export function createVideoSettingsMenuPanel({
|
|
document: documentRef = null,
|
|
video = null,
|
|
index = 0,
|
|
capabilities = {},
|
|
designSystem = null,
|
|
} = {}) {
|
|
const doc = resolveDocument(documentRef);
|
|
if (!doc) {
|
|
return null;
|
|
}
|
|
normalizeDesignSystemContext(designSystem);
|
|
|
|
const metadata = resolveVideoMetadata(video);
|
|
const normalizedIndex = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
|
|
const perms = {
|
|
canEdit: capabilities?.canEdit === true,
|
|
canRevert: capabilities?.canRevert === true,
|
|
canDelete: capabilities?.canDelete === true,
|
|
};
|
|
|
|
const { panel, list } = ensureMenuContainer(doc, ["w-44", "p-0"]);
|
|
panel.dataset.menu = "video-settings";
|
|
|
|
appendMenuAction(doc, list, {
|
|
text: "Edit",
|
|
action: "edit",
|
|
dataset: {
|
|
index: String(normalizedIndex),
|
|
eventId: metadata.id,
|
|
},
|
|
});
|
|
|
|
if (perms.canRevert) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Revert",
|
|
action: "revert",
|
|
variant: "critical",
|
|
dataset: {
|
|
index: String(normalizedIndex),
|
|
eventId: metadata.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (perms.canDelete) {
|
|
appendMenuAction(doc, list, {
|
|
text: "Delete All",
|
|
action: "delete",
|
|
variant: "critical",
|
|
dataset: {
|
|
index: String(normalizedIndex),
|
|
eventId: metadata.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
return panel;
|
|
}
|