Asset marketplace

This commit is contained in:
2026-01-10 01:24:59 +01:00
parent b1dd57da82
commit c3736e682b
22 changed files with 1342 additions and 50 deletions

View File

@@ -4,6 +4,7 @@ import { createCustomAssetModal } from "./customAssets.js";
let adminConsole;
const customAssetModal = createCustomAssetModal({
broadcaster,
adminChannels: ADMIN_CHANNELS,
showToast: globalThis.showToast,
onAssetSaved: (asset) => adminConsole?.handleCustomAssetSaved(asset),
});

View File

@@ -111,8 +111,8 @@ export function createAdminConsole({
});
}
const customAssetButton = document.getElementById("custom-asset-button");
if (customAssetButton && customAssetModal?.openNew) {
customAssetButton.addEventListener("click", () => customAssetModal.openNew());
if (customAssetButton && customAssetModal?.openLauncher) {
customAssetButton.addEventListener("click", () => customAssetModal.openLauncher());
}
globalThis.addEventListener("resize", () => {
resizeCanvas();

View File

@@ -1,6 +1,24 @@
export function createCustomAssetModal({ broadcaster, showToast = globalThis.showToast, onAssetSaved }) {
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 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");
@@ -11,7 +29,10 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
const attachmentList = document.getElementById("custom-asset-attachment-list");
const attachmentHint = document.getElementById("custom-asset-attachment-hint");
let currentAssetId = null;
let pendingLogoFile = null;
let logoRemoved = false;
let attachmentState = [];
let marketplaceEntries = [];
const resetErrors = () => {
if (formErrorWrapper) {
@@ -25,6 +46,30 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
}
};
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");
};
@@ -34,9 +79,17 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
};
const openNew = () => {
closeLaunchModal();
if (userNameInput) {
userNameInput.value = "";
}
if (descriptionInput) {
descriptionInput.value = "";
}
if (publicCheckbox) {
publicCheckbox.checked = false;
}
resetLogoState();
if (userSourceTextArea) {
userSourceTextArea.value = "";
userSourceTextArea.disabled = false;
@@ -57,6 +110,19 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
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.value = "";
userSourceTextArea.placeholder = "Loading script...";
@@ -119,18 +185,28 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
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 })
saveCodeAsset({ name, src, assetId, description, isPublic })
.then((asset) => {
if (asset) {
onAssetSaved?.(asset);
return syncLogoChanges(asset).then((updated) => {
onAssetSaved?.(updated || asset);
return updated || asset;
});
}
return null;
})
.then((asset) => {
closeModal();
showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success");
if (asset) {
showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success");
}
})
.catch((e) => {
showToast?.("Unable to save custom asset. Please try again.", "error");
@@ -145,6 +221,29 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
return false;
};
if (launchModal) {
launchModal.addEventListener("click", (event) => {
if (event.target === launchModal) {
closeLaunchModal();
}
});
}
if (launchNewButton) {
launchNewButton.addEventListener("click", () => openNew());
}
if (launchMarketplaceButton) {
launchMarketplaceButton.addEventListener("click", () => openMarketplaceModal());
}
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) {
@@ -158,6 +257,36 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
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];
@@ -187,7 +316,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
});
}
return { openNew, openEditor };
return { openLauncher: openLaunchModal, openNew, openEditor };
function setAttachmentState(assetId, attachments) {
currentAssetId = assetId || null;
@@ -300,8 +429,13 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
});
}
function saveCodeAsset({ name, src, assetId }) {
const payload = { name, source: src };
function saveCodeAsset({ name, src, assetId, description, isPublic }) {
const payload = {
name,
source: src,
description: description || null,
isPublic,
};
const method = assetId ? "PUT" : "POST";
const url = assetId
? `/api/channels/${encodeURIComponent(broadcaster)}/assets/${assetId}/code`
@@ -318,6 +452,195 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
});
}
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) {
throw new Error("Failed to upload logo");
}
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;
}
marketplaceEntries.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 importButton = document.createElement("button");
importButton.type = "button";
importButton.className = "primary";
importButton.textContent = "Import";
importButton.addEventListener("click", () => importMarketplaceScript(entry));
actions.appendChild(importButton);
card.appendChild(content);
card.appendChild(actions);
marketplaceList.appendChild(card);
});
}
function importMarketplaceScript(entry) {
if (!entry?.id) {
return;
}
const target = marketplaceChannelSelect?.value || broadcaster;
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) => {
closeMarketplaceModal();
showToast?.("Script imported.", "success");
onAssetSaved?.(asset);
})
.catch((error) => {
console.error(error);
showToast?.("Unable to import script. Please try again.", "error");
});
}
function debounce(fn, wait = 150) {
let timeout;
return (...args) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => fn(...args), wait);
};
}
function getUserJavaScriptSourceError(src) {
let ast;