const LS_KEY_MARKETPLACE_URL = 'wb_marketplace_url'; const DEFAULT_URL_PLACEHOLDER = 'https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/marketplace.json'; const INSTALLED_EXTENSIONS_API = '/api/extensions'; const UPDATE_EXTENSIONS_API = '/api/extensions/update'; // DOM Elements const dom = { content: document.getElementById('marketplace-content'), configPanel: document.getElementById('config-panel'), inputUrl: document.getElementById('source-url-input'), // Filters & Toggles filterType: document.getElementById('filter-type'), filterLang: document.getElementById('filter-lang'), btnNsfw: document.getElementById('btn-toggle-nsfw'), // Buttons btnConfig: document.getElementById('btn-configure'), btnSaveSource: document.getElementById('btn-save-source'), btnResetSource: document.getElementById('btn-reset-source'), btnUpdateAll: document.getElementById('btn-update-all'), // Tabs tabs: document.querySelectorAll('.tab-btn'), // Modal modal: document.getElementById('customModal'), modalTitle: document.getElementById('modalTitle'), modalMsg: document.getElementById('modalMessage'), modalConfirm: document.getElementById('modalConfirmButton'), modalClose: document.getElementById('modalCloseButton') }; // State let state = { url: localStorage.getItem(LS_KEY_MARKETPLACE_URL) || null, metadata: {}, installed: [], currentTab: 'marketplace', showNsfw: false // Default to HIDDEN }; /* --- Initialization --- */ async function init() { setupEventListeners(); if (!state.url) { renderWelcomeState(); } else { await loadData(); } } function setupEventListeners() { // Config Panel dom.btnConfig.addEventListener('click', () => { dom.configPanel.classList.toggle('hidden'); if(!dom.configPanel.classList.contains('hidden')) dom.inputUrl.focus(); }); dom.btnSaveSource.addEventListener('click', saveSource); dom.btnResetSource.addEventListener('click', () => { dom.inputUrl.value = DEFAULT_URL_PLACEHOLDER; saveSource(); }); // Filters dom.filterType.addEventListener('change', render); dom.filterLang.addEventListener('change', render); // NSFW Toggle dom.btnNsfw.addEventListener('click', toggleNsfw); // Tabs dom.tabs.forEach(tab => { tab.addEventListener('click', () => { dom.tabs.forEach(t => t.classList.remove('active')); tab.classList.add('active'); state.currentTab = tab.dataset.tab; if (state.currentTab === 'installed') dom.btnUpdateAll.classList.remove('hidden'); else dom.btnUpdateAll.classList.add('hidden'); render(); }); }); if(dom.btnUpdateAll) dom.btnUpdateAll.addEventListener('click', handleUpdateAll); // Modal dom.modalClose.addEventListener('click', hideModal); } /* --- Logic --- */ function toggleNsfw() { state.showNsfw = !state.showNsfw; // Update Button UI if (state.showNsfw) { dom.btnNsfw.classList.add('btn-danger'); // Red for ALERT/NSFW dom.btnNsfw.classList.remove('btn-secondary'); dom.btnNsfw.innerHTML = ` NSFW: ON `; } else { dom.btnNsfw.classList.remove('btn-danger'); dom.btnNsfw.classList.add('btn-secondary'); dom.btnNsfw.innerHTML = ` NSFW: Off `; } render(); } function saveSource() { const url = dom.inputUrl.value.trim(); if (!url) return window.NotificationUtils.error('Please enter a valid URL'); localStorage.setItem(LS_KEY_MARKETPLACE_URL, url); state.url = url; dom.configPanel.classList.add('hidden'); window.NotificationUtils.success('Source updated'); loadData(); } async function loadData() { renderLoading(); try { const proxyUrl = `/api/proxy?url=${encodeURIComponent(state.url)}`; const [metaRes, installedRes] = await Promise.all([ fetch(proxyUrl).then(r => r.ok ? r.json() : null), fetch(INSTALLED_EXTENSIONS_API).then(r => r.json()) ]); if (!metaRes) throw new Error('Failed to fetch marketplace JSON'); state.metadata = metaRes.extensions || {}; state.installed = (installedRes.extensions || []).map(e => e.toLowerCase()); render(); } catch (error) { console.error(error); renderError(error.message); } } /* --- Rendering --- */ function render() { dom.content.innerHTML = ''; const typeFilter = dom.filterType.value; const langFilter = dom.filterLang.value; let items = []; // 1. Prepare items if (state.currentTab === 'marketplace') { items = Object.entries(state.metadata).map(([id, data]) => ({ id, ...data, isInstalled: state.installed.includes(id.toLowerCase()) })); } else { const metaItems = Object.entries(state.metadata) .filter(([id]) => state.installed.includes(id.toLowerCase())) .map(([id, data]) => ({ id, ...data, isInstalled: true })); items = [...metaItems]; state.installed.forEach(instId => { if (!items.find(i => i.id.toLowerCase() === instId)) { items.push({ id: instId, name: capitalize(instId), type: 'Local', description: 'Locally installed.', author: '?', lang: 'Local', isInstalled: true, isLocal: true }); } }); } // 2. Filter items = items.filter(item => { // NSFW Filter logic if (!state.showNsfw && item.nsfw) return false; // Type Filter if (typeFilter !== 'All' && item.type !== typeFilter && item.type !== 'Local') return false; // Lang Filter if (langFilter !== 'All') { const itemLang = (item.lang || 'en').toLowerCase(); // Si es image-board, ignoramos el filtro de idioma (ya que no tienen idioma) // O podemos decidir que siempre pasen, o que solo pasen si el filtro es "All". // Para simplificar: Si es image-board, pasa el filtro de idioma automáticamente. if (item.type !== 'image-board' && itemLang !== 'multi' && itemLang !== langFilter.toLowerCase()) return false; } return true; }); const grouped = groupBy(items, 'type'); const types = Object.keys(grouped).sort(); if (types.length === 0) return renderEmptyState(); types.forEach(type => { const section = document.createElement('div'); section.className = 'mp-section'; const title = document.createElement('h2'); title.className = 'category-title'; title.innerText = formatType(type); const grid = document.createElement('div'); grid.className = 'mp-grid'; grouped[type].forEach(ext => grid.appendChild(createCard(ext))); section.appendChild(title); section.appendChild(grid); dom.content.appendChild(section); }); } function createCard(ext) { const card = document.createElement('div'); card.className = `mp-card ${ext.nsfw ? 'card-nsfw' : ''}`; // Icon logic const iconSrc = ext.icon || '/public/assets/waifuboards.ico'; // Button Logic let btnClass = 'btn-primary'; let btnText = 'Install'; if (ext.isInstalled) { btnClass = 'btn-danger'; btnText = 'Uninstall'; } else if (ext.broken) { btnClass = 'btn-secondary'; btnText = 'Broken'; } // Language Pill Logic let langHtml = ''; // Solo mostramos lenguaje si NO es un image-board if (ext.type !== 'image-board') { const langCode = (ext.lang || 'EN').toLowerCase(); const langClass = `lang-${langCode}`; const langLabel = langCode === 'multi' ? 'MULTI' : langCode.toUpperCase(); langHtml = `${langLabel}`; } card.innerHTML = `

${ext.name}

by ${ext.author || 'Unknown'}
${langHtml} ${ext.isInstalled ? 'INSTALLED' : ''} ${ext.nsfw ? 'NSFW' : ''}

${ext.description || 'No description provided.'}

`; const btn = card.querySelector('button'); if (!ext.broken || ext.isInstalled) { btn.onclick = () => ext.isInstalled ? confirmUninstall(ext) : installExtension(ext); } return card; } /* --- Actions & Helpers --- */ async function installExtension(ext) { if (!ext.entry) return window.NotificationUtils.error('Invalid extension entry point'); try { window.NotificationUtils.info(`Installing ${ext.name}...`); const res = await fetch('/api/extensions/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: ext.entry }) }); if (res.ok) { window.NotificationUtils.success(`${ext.name} Installed!`); state.installed.push(ext.id.toLowerCase()); render(); } else throw new Error('Install failed'); } catch (e) { window.NotificationUtils.error(`Failed to install ${ext.name}`); } } function confirmUninstall(ext) { showModal('Uninstall Extension', `Remove ${ext.name}?`, true, () => uninstallExtension(ext)); } async function uninstallExtension(ext) { try { const res = await fetch('/api/extensions/uninstall', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: ext.id + '.js' }) }); if (res.ok) { state.installed = state.installed.filter(id => id !== ext.id.toLowerCase()); window.NotificationUtils.success(`${ext.name} removed.`); render(); } } catch (e) { window.NotificationUtils.error('Uninstall failed'); } } async function handleUpdateAll() { const btn = dom.btnUpdateAll; const oldText = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'Updating...'; try { const res = await fetch(UPDATE_EXTENSIONS_API, { method: 'POST' }); const data = await res.json(); if (data.updated?.length) { window.NotificationUtils.success(`Updated ${data.updated.length} extensions.`); loadData(); } else window.NotificationUtils.info('All up to date.'); } catch (e) { window.NotificationUtils.error('Bulk update failed.'); } finally { btn.disabled = false; btn.innerHTML = oldText; } } function renderWelcomeState() { dom.content.innerHTML = `
🔌

Configure Source

Configure a source URL to start.

`; } function renderEmptyState() { dom.content.innerHTML = `
🔍

No extensions found.

`; } function renderLoading() { dom.content.innerHTML = '

Loading...

'; } function renderError(msg) { dom.content.innerHTML = `

Error: ${msg}

`; } function showModal(title, msg, hasConfirm, onConfirm) { dom.modalTitle.innerText = title; dom.modalMsg.innerText = msg; dom.modal.classList.remove('hidden'); if (hasConfirm) { dom.modalConfirm.classList.remove('hidden'); dom.modalConfirm.onclick = () => { hideModal(); if (onConfirm) onConfirm(); }; } else dom.modalConfirm.classList.add('hidden'); } function hideModal() { dom.modal.classList.add('hidden'); } function groupBy(arr, key) { return arr.reduce((acc, i) => ((acc[i[key] || 'Other'] = acc[i[key] || 'Other'] || []).push(i), acc), {}); } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function formatType(t) { return t.replace(/-/g, ' ').toUpperCase(); } document.addEventListener('DOMContentLoaded', init);