Files
server/src/main/resources/static/js/customAssets.js
2026-02-13 14:16:52 +01:00

1236 lines
45 KiB
JavaScript

// TODO: Code smell Large modal module with extensive mutable state and mixed UI/network responsibilities.
export function createCustomAssetModal({
broadcaster,
adminChannels = [],
showToast = globalThis.showToast,
onAssetSaved,
}) {
const launchModal = document.getElementById("custom-asset-launch-modal");
const launchNewButton = document.getElementById("custom-asset-launch-new");
const launchMarketplaceButton = document.getElementById("custom-asset-launch-marketplace");
const assetFileInput = document.getElementById("asset-file");
const marketplaceModal = document.getElementById("custom-asset-marketplace-modal");
const marketplaceCloseButton = document.getElementById("custom-asset-marketplace-close");
const marketplaceSearchInput = document.getElementById("custom-asset-marketplace-search");
const marketplaceList = document.getElementById("custom-asset-marketplace-list");
const marketplaceChannelSelect = document.getElementById("custom-asset-marketplace-channel");
const assetModal = document.getElementById("custom-asset-modal");
const userNameInput = document.getElementById("custom-asset-name");
const descriptionInput = document.getElementById("custom-asset-description");
const publicCheckbox = document.getElementById("custom-asset-public");
const logoInput = document.getElementById("custom-asset-logo-file");
const logoPreview = document.getElementById("custom-asset-logo-preview");
const logoClearButton = document.getElementById("custom-asset-logo-clear");
const userSourceTextArea = document.getElementById("custom-asset-code");
const formErrorWrapper = document.getElementById("custom-asset-error");
const jsErrorTitle = document.getElementById("js-error-title");
const jsErrorDetails = document.getElementById("js-error-details");
const form = document.getElementById("custom-asset-form");
const cancelButton = document.getElementById("custom-asset-cancel");
const attachmentInput = document.getElementById("custom-asset-attachment-file");
const attachmentList = document.getElementById("custom-asset-attachment-list");
const attachmentHint = document.getElementById("custom-asset-attachment-hint");
const allowedDomainInput = document.getElementById("custom-asset-allowed-domain");
const allowedDomainList = document.getElementById("custom-asset-allowed-domain-list");
const allowedDomainAddButton = document.getElementById("custom-asset-allowed-domain-add");
let codeEditor = null;
let currentAssetId = null;
let pendingLogoFile = null;
let logoRemoved = false;
let attachmentState = [];
let allowedDomainState = [];
let marketplaceEntries = [];
const resetErrors = () => {
if (formErrorWrapper) {
formErrorWrapper.classList.add("hidden");
}
if (jsErrorTitle) {
jsErrorTitle.textContent = "";
}
if (jsErrorDetails) {
jsErrorDetails.textContent = "";
}
};
const normalizeAllowedDomain = (value) => {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
const candidate = trimmed.includes("://") ? trimmed : `https://${trimmed}`;
try {
const url = new URL(candidate);
if (!url.hostname) {
return null;
}
const host = url.hostname.toLowerCase();
const port = url.port ? `:${url.port}` : "";
return `${host}${port}`;
} catch (_error) {
return null;
}
};
const setAllowedDomainState = (domains) => {
allowedDomainState = Array.isArray(domains)
? domains
.map((domain) => normalizeAllowedDomain(domain))
.filter((domain, index, list) => domain && list.indexOf(domain) === index)
: [];
renderAllowedDomains();
if (allowedDomainInput) {
allowedDomainInput.value = "";
}
};
const removeAllowedDomain = (domain) => {
allowedDomainState = allowedDomainState.filter((item) => item !== domain);
renderAllowedDomains();
};
const addAllowedDomain = (value) => {
const normalized = normalizeAllowedDomain(value);
if (!normalized) {
showToast?.("Enter a valid domain like api.example.com.", "error");
return;
}
if (allowedDomainState.includes(normalized)) {
showToast?.("Domain already added.", "info");
if (allowedDomainInput) allowedDomainInput.value = "";
return;
}
if (allowedDomainState.length >= 32) {
showToast?.("You can allow up to 32 domains per script.", "error");
return;
}
allowedDomainState = [...allowedDomainState, normalized];
renderAllowedDomains();
if (allowedDomainInput) {
allowedDomainInput.value = "";
}
};
function renderAllowedDomains() {
if (!allowedDomainList) {
return;
}
allowedDomainList.innerHTML = "";
if (!allowedDomainState.length) {
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "No external domains allowed (only same-origin requests).";
allowedDomainList.appendChild(empty);
return;
}
allowedDomainState.forEach((domain) => {
const item = document.createElement("li");
item.className = "attachment-item";
const meta = document.createElement("div");
meta.className = "attachment-meta";
const name = document.createElement("strong");
name.textContent = domain;
meta.appendChild(name);
const actions = document.createElement("div");
actions.className = "attachment-actions-row";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "secondary danger";
remove.textContent = "Remove";
remove.addEventListener("click", () => removeAllowedDomain(domain));
actions.appendChild(remove);
item.appendChild(meta);
item.appendChild(actions);
allowedDomainList.appendChild(item);
});
}
const registerCodeEditorLint = () => {
const CodeMirror = globalThis.CodeMirror;
if (!CodeMirror?.registerHelper || CodeMirror.__customAssetLintRegistered) {
return;
}
CodeMirror.__customAssetLintRegistered = true;
CodeMirror.registerHelper("lint", "javascript", (text) => {
const parser = globalThis.acorn;
if (!parser) {
return [];
}
if (!text.trim()) {
return [];
}
let ast;
try {
ast = parser.parse(text, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
} catch (e) {
const line = Math.max(0, (e.loc?.line || 1) - 1);
const ch = Math.max(0, e.loc?.column || 0);
return [
{
from: CodeMirror.Pos(line, ch),
to: CodeMirror.Pos(line, ch + 1),
message: e.message,
severity: "error",
},
];
}
let hasInit = false;
let hasTick = false;
const isFunctionNode = (node) =>
node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
const markFunctionName = (name) => {
if (name === "init") hasInit = true;
if (name === "tick") hasTick = true;
};
const isModuleExportsMember = (node) =>
node &&
node.type === "MemberExpression" &&
node.object?.type === "Identifier" &&
node.object.name === "module" &&
node.property?.type === "Identifier" &&
node.property.name === "exports";
const checkObjectExpression = (objectExpression) => {
if (!objectExpression || objectExpression.type !== "ObjectExpression") {
return;
}
for (const property of objectExpression.properties || []) {
if (property.type !== "Property") {
continue;
}
const keyName = property.key?.type === "Identifier" ? property.key.name : property.key?.value;
if (keyName && isFunctionNode(property.value)) {
markFunctionName(keyName);
}
}
};
for (const node of ast.body) {
if (node.type === "FunctionDeclaration") {
markFunctionName(node.id?.name);
continue;
}
if (node.type !== "ExpressionStatement") continue;
const expr = node.expression;
if (expr.type !== "AssignmentExpression") continue;
const left = expr.left;
const right = expr.right;
if (left.type === "Identifier" && left.name === "exports" && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (isModuleExportsMember(left) && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (left.type === "MemberExpression" && left.property.type === "Identifier" && isFunctionNode(right)) {
if (
(left.object.type === "Identifier" && left.object.name === "exports") ||
isModuleExportsMember(left.object)
) {
markFunctionName(left.property.name);
}
}
}
const annotations = [];
if (!hasInit) {
annotations.push({
from: CodeMirror.Pos(0, 0),
to: CodeMirror.Pos(0, 1),
message: "Missing function: init",
severity: "error",
});
}
if (!hasTick) {
annotations.push({
from: CodeMirror.Pos(0, 0),
to: CodeMirror.Pos(0, 1),
message: "Missing function: tick",
severity: "error",
});
}
return annotations;
});
};
const createCodeEditor = () => {
const CodeMirror = globalThis.CodeMirror;
if (!CodeMirror || !userSourceTextArea) {
return;
}
registerCodeEditorLint();
codeEditor = CodeMirror.fromTextArea(userSourceTextArea, {
mode: "javascript",
lineNumbers: true,
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
gutters: ["CodeMirror-lint-markers"],
lint: true,
placeholder: userSourceTextArea.placeholder,
});
codeEditor.getWrapperElement().classList.add("code-editor");
codeEditor.setSize(null, "420px");
codeEditor.on("change", () => {
userSourceTextArea.value = codeEditor.getValue();
});
};
const getCodeSource = () => (codeEditor ? codeEditor.getValue() : userSourceTextArea?.value);
const setCodeValue = (value) => {
if (codeEditor) {
codeEditor.setValue(value ?? "");
codeEditor.save();
codeEditor.refresh();
} else if (userSourceTextArea) {
userSourceTextArea.value = value ?? "";
}
};
const setCodeReadOnly = (isReadOnly) => {
if (codeEditor) {
codeEditor.setOption("readOnly", isReadOnly ? "nocursor" : false);
codeEditor.refresh();
}
if (userSourceTextArea) {
userSourceTextArea.disabled = isReadOnly;
}
};
const setCodePlaceholder = (placeholder) => {
if (codeEditor) {
codeEditor.setOption("placeholder", placeholder);
}
if (userSourceTextArea) {
userSourceTextArea.placeholder = placeholder;
}
};
const openLaunchModal = () => {
launchModal?.classList.remove("hidden");
};
const closeLaunchModal = () => {
launchModal?.classList.add("hidden");
};
const openMarketplaceModal = () => {
closeLaunchModal();
marketplaceModal?.classList.remove("hidden");
if (marketplaceChannelSelect) {
marketplaceChannelSelect.value = broadcaster?.toLowerCase() || marketplaceChannelSelect.value;
}
if (marketplaceSearchInput) {
marketplaceSearchInput.value = "";
}
loadMarketplace();
};
const closeMarketplaceModal = () => {
marketplaceModal?.classList.add("hidden");
};
const openModal = () => {
assetModal?.classList.remove("hidden");
};
const closeModal = () => {
assetModal?.classList.add("hidden");
};
const openNew = () => {
closeLaunchModal();
if (userNameInput) {
userNameInput.value = "";
}
if (descriptionInput) {
descriptionInput.value = "";
}
if (publicCheckbox) {
publicCheckbox.checked = false;
}
resetLogoState();
if (userSourceTextArea) {
userSourceTextArea.dataset.assetId = "";
}
setCodeValue("");
setCodeReadOnly(false);
setCodePlaceholder(
"function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};",
);
setAttachmentState(null, []);
setAllowedDomainState([]);
resetErrors();
openModal();
};
const openEditor = (asset) => {
if (!asset) {
return;
}
resetErrors();
if (userNameInput) {
userNameInput.value = asset.name || "";
}
if (descriptionInput) {
descriptionInput.value = asset.description || "";
}
if (publicCheckbox) {
publicCheckbox.checked = !!asset.isPublic;
}
resetLogoState();
if (logoPreview && asset.logoUrl) {
const img = document.createElement("img");
img.src = asset.logoUrl;
img.alt = asset.name || "Script logo";
logoPreview.appendChild(img);
}
if (userSourceTextArea) {
userSourceTextArea.dataset.assetId = asset.id;
}
setCodeValue("");
setCodeReadOnly(true);
setCodePlaceholder("Loading script...");
setAttachmentState(asset.id, asset.scriptAttachments || []);
setAllowedDomainState(asset.allowedDomains || []);
openModal();
fetch(asset.url)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load script");
}
return response.text();
})
.then((text) => {
setCodeReadOnly(false);
setCodeValue(text);
})
.catch(() => {
setCodeReadOnly(false);
setCodeValue("");
showToast?.("Unable to load script content.", "error");
});
};
const handleFormSubmit = (formEvent) => {
formEvent.preventDefault();
const src = getCodeSource();
const error = getUserJavaScriptSourceError(src);
if (error) {
if (jsErrorTitle) {
jsErrorTitle.textContent = error.title;
}
if (jsErrorDetails) {
jsErrorDetails.textContent = error.details;
}
if (formErrorWrapper) {
formErrorWrapper.classList.remove("hidden");
}
codeEditor?.performLint?.();
return false;
}
resetErrors();
const name = userNameInput?.value?.trim();
if (!name) {
if (jsErrorTitle) {
jsErrorTitle.textContent = "Missing name";
}
if (jsErrorDetails) {
jsErrorDetails.textContent = "Please provide a name for your custom asset.";
}
if (formErrorWrapper) {
formErrorWrapper.classList.remove("hidden");
}
return false;
}
const assetId = userSourceTextArea?.dataset?.assetId;
const description = descriptionInput?.value?.trim();
const isPublic = !!publicCheckbox?.checked;
const submitButton = formEvent.currentTarget?.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = "Saving...";
}
saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains: allowedDomainState })
.then((asset) => {
if (asset) {
return syncLogoChanges(asset).then((updated) => {
onAssetSaved?.(updated || asset);
return updated || asset;
});
}
return null;
})
.then((asset) => {
closeModal();
if (asset) {
showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success");
}
})
.catch((e) => {
showToast?.(e?.message || "Unable to save custom asset. Please try again.", "error");
console.error(e);
})
.finally(() => {
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = "Test and save";
}
});
return false;
};
createCodeEditor();
if (launchModal) {
launchModal.addEventListener("click", (event) => {
if (event.target === launchModal) {
closeLaunchModal();
}
});
}
if (launchNewButton) {
launchNewButton.addEventListener("click", () => openNew());
}
if (launchMarketplaceButton) {
launchMarketplaceButton.addEventListener("click", () => openMarketplaceModal());
}
if (assetFileInput) {
assetFileInput.addEventListener("change", (event) => {
if (event.target?.files?.length) {
closeLaunchModal();
}
});
}
if (marketplaceModal) {
marketplaceModal.addEventListener("click", (event) => {
if (event.target === marketplaceModal) {
closeMarketplaceModal();
}
});
}
if (marketplaceCloseButton) {
marketplaceCloseButton.addEventListener("click", () => closeMarketplaceModal());
}
if (assetModal) {
assetModal.addEventListener("click", (event) => {
if (event.target === assetModal) {
closeModal();
}
});
}
if (form) {
form.addEventListener("submit", handleFormSubmit);
}
if (cancelButton) {
cancelButton.addEventListener("click", () => closeModal());
}
if (logoInput) {
logoInput.addEventListener("change", (event) => {
const file = event.target?.files?.[0];
if (!file) {
return;
}
pendingLogoFile = file;
logoRemoved = false;
renderLogoPreview(file);
});
}
if (logoClearButton) {
logoClearButton.addEventListener("click", () => {
logoRemoved = true;
pendingLogoFile = null;
if (logoInput) {
logoInput.value = "";
}
clearLogoPreview();
});
}
if (marketplaceSearchInput) {
const handler = debounce((event) => {
loadMarketplace(event.target?.value);
}, 250);
marketplaceSearchInput.addEventListener("input", handler);
}
if (marketplaceChannelSelect) {
buildChannelOptions();
}
if (attachmentInput) {
attachmentInput.addEventListener("change", (event) => {
const file = event.target?.files?.[0];
if (!file) {
return;
}
if (!currentAssetId) {
showToast?.("Save the script before adding attachments.", "info");
attachmentInput.value = "";
return;
}
uploadAttachment(file)
.then((attachment) => {
if (attachment) {
attachmentState = [...attachmentState, attachment];
renderAttachmentList();
showToast?.("Attachment added.", "success");
}
})
.catch((error) => {
console.error(error);
showToast?.(error?.message || "Unable to upload attachment. Please try again.", "error");
})
.finally(() => {
attachmentInput.value = "";
});
});
}
if (allowedDomainAddButton) {
allowedDomainAddButton.addEventListener("click", () => addAllowedDomain(allowedDomainInput?.value));
}
if (allowedDomainInput) {
allowedDomainInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
addAllowedDomain(event.target?.value);
}
});
}
return { openLauncher: openLaunchModal, openNew, openEditor };
function setAttachmentState(assetId, attachments) {
currentAssetId = assetId || null;
attachmentState = Array.isArray(attachments) ? [...attachments] : [];
renderAttachmentList();
}
function renderAttachmentList() {
if (!attachmentList) {
return;
}
attachmentList.innerHTML = "";
if (!currentAssetId) {
if (attachmentInput) {
attachmentInput.disabled = true;
}
if (attachmentHint) {
attachmentHint.textContent = "Save the script before adding attachments.";
}
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "Attachments will appear here once the script is saved.";
attachmentList.appendChild(empty);
return;
}
if (attachmentInput) {
attachmentInput.disabled = false;
}
if (attachmentHint) {
attachmentHint.textContent =
"Attachments are available to this script only and are not visible on the canvas.";
}
if (!attachmentState.length) {
const empty = document.createElement("li");
empty.className = "attachment-empty";
empty.textContent = "No attachments yet.";
attachmentList.appendChild(empty);
return;
}
attachmentState.forEach((attachment) => {
const item = document.createElement("li");
item.className = "attachment-item";
const meta = document.createElement("div");
meta.className = "attachment-meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Untitled";
const type = document.createElement("span");
type.textContent = attachment.assetType || attachment.mediaType || "Attachment";
meta.appendChild(name);
meta.appendChild(type);
const actions = document.createElement("div");
actions.className = "attachment-actions-row";
if (attachment.url) {
const link = document.createElement("a");
link.href = attachment.url;
link.target = "_blank";
link.rel = "noopener";
link.className = "button ghost";
link.textContent = "Open";
actions.appendChild(link);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "secondary danger";
remove.textContent = "Remove";
remove.addEventListener("click", () => removeAttachment(attachment.id));
actions.appendChild(remove);
item.appendChild(meta);
item.appendChild(actions);
attachmentList.appendChild(item);
});
}
function uploadAttachment(file) {
const payload = new FormData();
payload.append("file", file);
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments`, {
method: "POST",
body: payload,
}).then((response) => {
if (!response.ok) {
return extractErrorMessage(response, "Failed to upload attachment").then((message) => {
throw new Error(message);
});
}
return response.json();
});
}
function extractErrorMessage(response, fallback) {
if (!response) {
return Promise.resolve(fallback);
}
return response
.json()
.then((data) => {
if (data?.message) {
return data.message;
}
if (data?.error) {
return data.error;
}
if (typeof data === "string" && data.trim()) {
return data;
}
return fallback;
})
.catch(() => response.text().then((text) => text?.trim() || fallback).catch(() => fallback));
}
function removeAttachment(attachmentId) {
if (!attachmentId || !currentAssetId) {
return;
}
fetch(
`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments/${attachmentId}`,
{ method: "DELETE" },
)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to delete attachment");
}
attachmentState = attachmentState.filter((attachment) => attachment.id !== attachmentId);
renderAttachmentList();
showToast?.("Attachment removed.", "success");
})
.catch((error) => {
console.error(error);
showToast?.("Unable to remove attachment. Please try again.", "error");
});
}
function saveCodeAsset({ name, src, assetId, description, isPublic, allowedDomains }) {
const payload = {
name,
source: src,
description: description || null,
isPublic,
allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [],
};
const method = assetId ? "PUT" : "POST";
const url = assetId
? `/api/channels/${encodeURIComponent(broadcaster)}/assets/${assetId}/code`
: `/api/channels/${encodeURIComponent(broadcaster)}/assets/code`;
return fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to save code asset");
}
return response.json();
});
}
function resetLogoState() {
pendingLogoFile = null;
logoRemoved = false;
if (logoInput) {
logoInput.value = "";
}
clearLogoPreview();
}
function clearLogoPreview() {
if (logoPreview) {
logoPreview.innerHTML = "";
}
}
function renderLogoPreview(file) {
if (!logoPreview) {
return;
}
clearLogoPreview();
const img = document.createElement("img");
img.alt = "Script logo preview";
if (file instanceof File) {
const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
}
logoPreview.appendChild(img);
}
function syncLogoChanges(asset) {
if (!asset?.id) {
return Promise.resolve(null);
}
if (logoRemoved) {
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${asset.id}/logo`, {
method: "DELETE",
}).then(() => {
logoRemoved = false;
return { ...asset, logoUrl: null };
});
}
if (!pendingLogoFile) {
return Promise.resolve(null);
}
const payload = new FormData();
payload.append("file", pendingLogoFile);
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${asset.id}/logo`, {
method: "POST",
body: payload,
}).then((response) => {
if (!response.ok) {
return extractErrorMessage(response, "Failed to upload logo").then((message) => {
throw new Error(message);
});
}
pendingLogoFile = null;
return response.json();
});
}
function buildChannelOptions() {
if (!marketplaceChannelSelect) {
return;
}
const channels = [broadcaster, ...adminChannels].filter(Boolean);
const uniqueChannels = [...new Set(channels.map((channel) => channel.toLowerCase()))];
marketplaceChannelSelect.innerHTML = "";
uniqueChannels.forEach((channel) => {
const option = document.createElement("option");
option.value = channel;
option.textContent = channel;
marketplaceChannelSelect.appendChild(option);
});
marketplaceChannelSelect.value = broadcaster?.toLowerCase() || uniqueChannels[0] || "";
}
function loadMarketplace(query = "") {
if (!marketplaceList) {
return;
}
const queryString = query ? `?query=${encodeURIComponent(query)}` : "";
marketplaceList.innerHTML = '<div class="marketplace-loading">Loading scripts...</div>';
fetch(`/api/marketplace/scripts${queryString}`)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load marketplace");
}
return response.json();
})
.then((entries) => {
marketplaceEntries = Array.isArray(entries) ? entries : [];
renderMarketplace();
})
.catch((error) => {
console.error(error);
marketplaceList.innerHTML =
'<div class="marketplace-empty">Unable to load marketplace scripts.</div>';
});
}
function renderMarketplace() {
if (!marketplaceList) {
return;
}
marketplaceList.innerHTML = "";
if (!marketplaceEntries.length) {
marketplaceList.innerHTML = '<div class="marketplace-empty">No scripts found.</div>';
return;
}
const sortedEntries = [...marketplaceEntries].sort((a, b) => {
const heartsDelta = (b.heartsCount ?? 0) - (a.heartsCount ?? 0);
if (heartsDelta !== 0) {
return heartsDelta;
}
return (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" });
});
sortedEntries.forEach((entry) => {
const card = document.createElement("div");
card.className = "marketplace-card";
if (entry.logoUrl) {
const logo = document.createElement("img");
logo.src = entry.logoUrl;
logo.alt = entry.name || "Script logo";
logo.className = "marketplace-logo";
card.appendChild(logo);
} else {
const placeholder = document.createElement("div");
placeholder.className = "marketplace-logo placeholder";
placeholder.innerHTML = '<i class="fa-solid fa-code"></i>';
card.appendChild(placeholder);
}
const content = document.createElement("div");
content.className = "marketplace-content";
const title = document.createElement("strong");
title.textContent = entry.name || "Untitled script";
const description = document.createElement("p");
description.textContent = entry.description || "No description provided.";
const meta = document.createElement("small");
meta.textContent = entry.broadcaster ? `By ${entry.broadcaster}` : "";
content.appendChild(title);
content.appendChild(description);
content.appendChild(meta);
const actions = document.createElement("div");
actions.className = "marketplace-actions";
const heartCountWrapper = document.createElement("div");
heartCountWrapper.className = "marketplace-heart-count";
const heartCountIcon = document.createElement("i");
heartCountIcon.className = "fa-solid fa-heart";
const heartCount = document.createElement("span");
heartCount.textContent = String(entry.heartsCount ?? 0);
heartCountWrapper.appendChild(heartCountIcon);
heartCountWrapper.appendChild(heartCount);
const heartButton = document.createElement("button");
heartButton.type = "button";
heartButton.className = "icon-button marketplace-heart-button";
heartButton.setAttribute("aria-label", "Heart script");
updateMarketplaceHeartButton(heartButton, entry);
heartButton.addEventListener("click", () =>
toggleMarketplaceHeart(entry, {
button: heartButton,
count: heartCount,
countWrapper: heartCountWrapper,
})
);
const importButton = document.createElement("button");
importButton.type = "button";
importButton.className = "icon-button";
importButton.setAttribute("aria-label", "Import script");
importButton.innerHTML = '<i class="icon fa-solid fa-cloud-download"></i>';
importButton.addEventListener("click", () => importMarketplaceScript(entry));
actions.appendChild(heartCountWrapper);
actions.appendChild(heartButton);
actions.appendChild(importButton);
card.appendChild(content);
card.appendChild(actions);
marketplaceList.appendChild(card);
});
}
function importMarketplaceScript(entry) {
if (!entry?.id) {
return;
}
const target = marketplaceChannelSelect?.value || broadcaster;
const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : [];
confirmDomainImport(allowedDomains)
.then((confirmed) => {
if (!confirmed) {
return null;
}
return fetch(`/api/marketplace/scripts/${entry.id}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetBroadcaster: target }),
}).then((response) => {
if (!response.ok) {
throw new Error("Failed to import script");
}
return response.json();
});
})
.then((asset) => {
if (!asset) {
return;
}
closeMarketplaceModal();
showToast?.("Script imported.", "success");
onAssetSaved?.(asset);
})
.catch((error) => {
console.error(error);
showToast?.(error?.message || "Unable to import script. Please try again.", "error");
});
}
function updateMarketplaceHeartButton(button, entry) {
if (!button || !entry) {
return;
}
button.classList.toggle("active", Boolean(entry.hearted));
button.setAttribute("aria-pressed", entry.hearted ? "true" : "false");
const iconClass = entry.hearted ? "fa-solid fa-heart" : "fa-regular fa-heart";
button.innerHTML = `<i class="icon ${iconClass}"></i>`;
}
function toggleMarketplaceHeart(entry, elements = {}) {
if (!entry?.id) {
return;
}
animateMarketplaceHeart(elements);
fetch(`/api/marketplace/scripts/${entry.id}/heart`, {
method: "POST",
headers: { "Content-Type": "application/json" },
})
.then((response) => {
if (!response.ok) {
throw new Error("Failed to update heart");
}
return response.json();
})
.then((updated) => {
entry.heartsCount = updated.heartsCount ?? entry.heartsCount ?? 0;
entry.hearted = updated.hearted ?? entry.hearted;
if (elements.count) {
elements.count.textContent = String(entry.heartsCount ?? 0);
}
if (elements.button) {
updateMarketplaceHeartButton(elements.button, entry);
}
animateMarketplaceHeart(elements);
setTimeout(() => renderMarketplace(), 300);
})
.catch((error) => {
console.error(error);
showToast?.("Unable to update heart. Please try again.", "error");
});
}
function animateMarketplaceHeart({ button, countWrapper } = {}) {
if (button) {
button.classList.remove("is-animating");
void button.offsetWidth;
button.classList.add("is-animating");
}
if (countWrapper) {
countWrapper.classList.remove("is-animating");
void countWrapper.offsetWidth;
countWrapper.classList.add("is-animating");
}
}
function debounce(fn, wait = 150) {
let timeout;
return (...args) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), wait);
};
}
function getUserJavaScriptSourceError(src) {
let ast;
const parser = globalThis.acorn;
if (!parser) {
return { title: "Parser unavailable", details: "JavaScript parser is not available yet." };
}
try {
ast = parser.parse(src, {
ecmaVersion: "latest",
sourceType: "script",
});
} catch (e) {
return { title: "Syntax Error", details: e.message };
}
let hasInit = false;
let hasTick = false;
const isFunctionNode = (node) =>
node && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression");
const markFunctionName = (name) => {
if (name === "init") hasInit = true;
if (name === "tick") hasTick = true;
};
const isModuleExportsMember = (node) =>
node &&
node.type === "MemberExpression" &&
node.object?.type === "Identifier" &&
node.object.name === "module" &&
node.property?.type === "Identifier" &&
node.property.name === "exports";
const checkObjectExpression = (objectExpression) => {
if (!objectExpression || objectExpression.type !== "ObjectExpression") {
return;
}
for (const property of objectExpression.properties || []) {
if (property.type !== "Property") {
continue;
}
const keyName = property.key?.type === "Identifier" ? property.key.name : property.key?.value;
if (keyName && isFunctionNode(property.value)) {
markFunctionName(keyName);
}
}
};
for (const node of ast.body) {
if (node.type === "FunctionDeclaration") {
markFunctionName(node.id?.name);
continue;
}
if (node.type !== "ExpressionStatement") continue;
const expr = node.expression;
if (expr.type !== "AssignmentExpression") continue;
const left = expr.left;
const right = expr.right;
if (left.type === "Identifier" && left.name === "exports" && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (isModuleExportsMember(left) && right.type === "ObjectExpression") {
checkObjectExpression(right);
continue;
}
if (left.type === "MemberExpression" && left.property.type === "Identifier" && isFunctionNode(right)) {
if (
(left.object.type === "Identifier" && left.object.name === "exports") ||
isModuleExportsMember(left.object)
) {
markFunctionName(left.property.name);
}
}
}
if (!hasInit) {
return {
title: "Missing function: init",
details: "Define a function named init or assign a function to exports.init/module.exports.init.",
};
}
if (!hasTick) {
return {
title: "Missing function: tick",
details: "Define a function named tick or assign a function to exports.tick/module.exports.tick.",
};
}
return undefined;
}
function confirmDomainImport(domains) {
if (!Array.isArray(domains) || domains.length === 0) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const dialog = document.createElement("div");
dialog.className = "modal-inner small";
const title = document.createElement("h3");
title.textContent = "Allow external domains?";
dialog.appendChild(title);
const copy = document.createElement("p");
copy.textContent = `This script requests network access to the following domains:`;
dialog.appendChild(copy);
const list = document.createElement("ul");
list.className = "domain-list";
domains.forEach((domain) => {
const item = document.createElement("li");
item.textContent = domain;
list.appendChild(item);
});
dialog.appendChild(list);
const buttons = document.createElement("div");
buttons.className = "form-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary";
cancel.textContent = "Cancel";
cancel.addEventListener("click", () => {
cleanup();
resolve(false);
});
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "primary";
confirm.textContent = "Allow & import";
confirm.addEventListener("click", () => {
cleanup();
resolve(true);
});
buttons.appendChild(cancel);
buttons.appendChild(confirm);
dialog.appendChild(buttons);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
cleanup();
resolve(false);
}
});
overlay.appendChild(dialog);
document.body.appendChild(overlay);
function cleanup() {
overlay.remove();
}
});
}
}