From a26f03f024677605140ad764d7f6df50d3f7e06a Mon Sep 17 00:00:00 2001 From: lenafx Date: Fri, 19 Dec 2025 23:12:53 +0100 Subject: [PATCH] new marketplace page --- .../api/extensions/extensions.controller.ts | 25 +- desktop/src/scripts/marketplace.js | 557 ++++++------------ desktop/views/css/marketplace.css | 95 +++ desktop/views/marketplace.html | 73 +-- .../api/extensions/extensions.controller.ts | 25 +- docker/src/scripts/marketplace.js | 557 ++++++------------ docker/views/marketplace.html | 64 +- 7 files changed, 522 insertions(+), 874 deletions(-) diff --git a/desktop/src/api/extensions/extensions.controller.ts b/desktop/src/api/extensions/extensions.controller.ts index 6b97931..c62a4eb 100644 --- a/desktop/src/api/extensions/extensions.controller.ts +++ b/desktop/src/api/extensions/extensions.controller.ts @@ -37,24 +37,33 @@ export async function getExtensionSettings(req: ExtensionNameRequest, reply: Fas } export async function installExtension(req: any, reply: FastifyReply) { - const { fileName } = req.body; + const { url } = req.body; - if (!fileName || !fileName.endsWith('.js')) { - return reply.code(400).send({ error: "Invalid extension fileName provided" }); + if (!url || typeof url !== 'string' || !url.endsWith('.js')) { + return reply.code(400).send({ error: "Invalid extension URL provided" }); } try { + const fileName = url.split('/').pop(); - const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}` + if (!fileName) { + return reply.code(400).send({ error: "Could not determine file name from URL" }); + } - await saveExtensionFile(fileName, downloadUrl); + await saveExtensionFile(fileName, url); req.server.log.info(`Extension installed: ${fileName}`); - return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` }); + return reply.code(200).send({ + success: true, + message: `Extension ${fileName} installed successfully.`, + }); } catch (error) { - req.server.log.error(`Failed to install extension ${fileName}:`, error); - return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` }); + req.server.log.error(`Failed to install extension from ${url}:`, error); + return reply.code(500).send({ + success: false, + error: "Failed to install extension.", + }); } } diff --git a/desktop/src/scripts/marketplace.js b/desktop/src/scripts/marketplace.js index 6180eb9..2e784fe 100644 --- a/desktop/src/scripts/marketplace.js +++ b/desktop/src/scripts/marketplace.js @@ -1,422 +1,221 @@ -const GITEA_INSTANCE = 'https://git.waifuboard.app'; -const REPO_OWNER = 'ItsSkaiya'; -const REPO_NAME = 'WaifuBoard-Extensions'; -let DETECTED_BRANCH = 'main'; -const API_URL_BASE = `${GITEA_INSTANCE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/contents`; - +const ORIGINAL_MARKETPLACE_URL = 'https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/marketplace.json'; +const MARKETPLACE_JSON_URL = `/api/proxy?url=${encodeURIComponent(ORIGINAL_MARKETPLACE_URL)}`; const INSTALLED_EXTENSIONS_API = '/api/extensions'; -const extensionsGrid = document.getElementById('extensions-grid'); +const marketplaceContent = document.getElementById('marketplace-content'); const filterSelect = document.getElementById('extension-filter'); -let allExtensionsData = []; - -const customModal = document.getElementById('customModal'); +const modal = document.getElementById('customModal'); const modalTitle = document.getElementById('modalTitle'); const modalMessage = document.getElementById('modalMessage'); +const modalConfirmBtn = document.getElementById('modalConfirmButton'); +const modalCloseBtn = document.getElementById('modalCloseButton'); -function getRawUrl(filename) { +let marketplaceMetadata = {}; +let installedExtensions = []; +let currentTab = 'marketplace'; - const targetUrl = `${GITEA_INSTANCE}/${REPO_OWNER}/${REPO_NAME}/raw/branch/main/${filename}`; - - const encodedUrl = encodeURIComponent(targetUrl); - - return `/api/proxy?url=${encodedUrl}`; -} - -function updateExtensionState(fileName, installed) { - const ext = allExtensionsData.find(e => e.fileName === fileName); - if (!ext) return; - - ext.isInstalled = installed; - ext.isLocal = installed && ext.isLocal; - - filterAndRenderExtensions(filterSelect?.value || 'All'); -} - -function formatExtensionName(fileName) { - return fileName.replace('.js', '') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/^[a-z]/, (char) => char.toUpperCase()); -} - -function getIconUrl(extensionDetails) { - return extensionDetails; -} - -async function getExtensionDetails(url) { +async function loadMarketplace() { + showSkeletons(); try { - const res = await fetch(url); - if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); - const text = await res.text(); + const [metaRes, installedRes] = await Promise.all([ + fetch(MARKETPLACE_JSON_URL).then(res => res.json()), + fetch(INSTALLED_EXTENSIONS_API).then(res => res.json()) + ]); - const regex = /(?:this\.|const\s+|let\s+|var\s+)?baseUrl\s*=\s*(["'`])(.*?)\1/i; - const match = text.match(regex); - let finalHostname = null; - if (match && match[2]) { - let rawUrl = match[2].trim(); - if (!rawUrl.startsWith('http')) rawUrl = 'https://' + rawUrl; - try { - const urlObj = new URL(rawUrl); - finalHostname = urlObj.hostname; - } catch(e) { - console.warn(`Could not parse baseUrl: ${rawUrl}`); - } + marketplaceMetadata = metaRes.extensions; + installedExtensions = (installedRes.extensions || []).map(e => e.toLowerCase()); + + initTabs(); + renderGroupedView(); + + if (filterSelect) { + filterSelect.addEventListener('change', () => renderGroupedView()); } - - const classMatch = text.match(/class\s+(\w+)/); - const name = classMatch ? classMatch[1] : null; - - let type = 'Image'; - if (text.includes('type = "book-board"') || text.includes("type = 'book-board'")) type = 'Book'; - else if (text.includes('type = "anime-board"') || text.includes("type = 'anime-board'")) type = 'Anime'; - - return { baseUrl: finalHostname, name, type }; - } catch (e) { - return { baseUrl: null, name: null, type: 'Unknown' }; + } catch (error) { + console.error('Error loading marketplace:', error); + marketplaceContent.innerHTML = `
Error al cargar el marketplace.
`; } } -function showCustomModal(title, message, isConfirm = false) { - return new Promise(resolve => { - - modalTitle.textContent = title; - modalMessage.textContent = message; - - const currentConfirmButton = document.getElementById('modalConfirmButton'); - const currentCloseButton = document.getElementById('modalCloseButton'); - - const newConfirmButton = currentConfirmButton.cloneNode(true); - currentConfirmButton.parentNode.replaceChild(newConfirmButton, currentConfirmButton); - - const newCloseButton = currentCloseButton.cloneNode(true); - currentCloseButton.parentNode.replaceChild(newCloseButton, currentCloseButton); - - if (isConfirm) { - - newConfirmButton.classList.remove('hidden'); - newConfirmButton.textContent = 'Confirm'; - newCloseButton.textContent = 'Cancel'; - } else { - - newConfirmButton.classList.add('hidden'); - newCloseButton.textContent = 'Close'; - } - - const closeModal = (confirmed) => { - customModal.classList.add('hidden'); - resolve(confirmed); +function initTabs() { + const tabs = document.querySelectorAll('.tab-button'); + tabs.forEach(tab => { + tab.onclick = () => { + tabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentTab = tab.dataset.tab; + renderGroupedView(); }; - - newConfirmButton.onclick = () => closeModal(true); - newCloseButton.onclick = () => closeModal(false); - - customModal.classList.remove('hidden'); }); } -function renderExtensionCard(extension, isInstalled, isLocalOnly = false) { +function renderGroupedView() { + marketplaceContent.innerHTML = ''; + const activeFilter = filterSelect.value; + const groups = {}; - const extensionName = formatExtensionName(extension.fileName || extension.name); - const extensionType = extension.type || 'Unknown'; + let listToRender = []; - let iconUrl; + if (currentTab === 'marketplace') { - if (extension.baseUrl && extension.baseUrl !== 'Local Install') { - - iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`; + for (const [id, data] of Object.entries(marketplaceMetadata)) { + listToRender.push({ + id, + ...data, + isInstalled: installedExtensions.includes(id.toLowerCase()) + }); + } } else { - const displayName = extensionName.replace(/\s/g, '+'); - iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`; + for (const [id, data] of Object.entries(marketplaceMetadata)) { + if (installedExtensions.includes(id.toLowerCase())) { + listToRender.push({ id, ...data, isInstalled: true }); + } + } + + installedExtensions.forEach(id => { + const existsInMeta = Object.keys(marketplaceMetadata).some(k => k.toLowerCase() === id); + if (!existsInMeta) { + listToRender.push({ + id: id, + name: id.charAt(0).toUpperCase() + id.slice(1), + type: 'Local', + author: 'Unknown', + isInstalled: true + }); + } + }); } + listToRender.forEach(ext => { + const type = ext.type || 'Other'; + + if (activeFilter !== 'All' && type !== activeFilter) return; + + if (!groups[type]) groups[type] = []; + groups[type].push(ext); + }); + + const sortedTypes = Object.keys(groups).sort(); + + if (sortedTypes.length === 0) { + marketplaceContent.innerHTML = `

No extensions found for this criteria.

`; + return; + } + + sortedTypes.forEach(type => { + const section = document.createElement('div'); + section.className = 'category-group'; + + const title = document.createElement('h2'); + title.className = 'marketplace-section-title'; + title.innerText = type.replace('-', ' '); + + const grid = document.createElement('div'); + grid.className = 'marketplace-grid'; + + groups[type].forEach(ext => grid.appendChild(createCard(ext))); + + section.appendChild(title); + section.appendChild(grid); + marketplaceContent.appendChild(section); + }); +} + +function createCard(ext) { const card = document.createElement('div'); - card.className = `extension-card grid-item extension-type-${extensionType.toLowerCase()}`; - card.dataset.path = extension.fileName || extension.name; - card.dataset.type = extensionType; + card.className = `extension-card ${ext.nsfw ? 'nsfw-ext' : ''} ${ext.broken ? 'broken-ext' : ''}`; - let buttonHtml; - let badgeHtml = ''; + const iconUrl = `https://www.google.com/s2/favicons?domain=${ext.domain}&sz=128` - if (isInstalled) { - - if (isLocalOnly) { - badgeHtml = 'Local'; - } else { - badgeHtml = 'Installed'; - } - buttonHtml = ` - - `; + let buttonHtml = ''; + if (ext.isInstalled) { + buttonHtml = ``; + } else if (ext.broken) { + buttonHtml = ``; } else { - - buttonHtml = ` - - `; + buttonHtml = ``; } card.innerHTML = ` - ${extensionName} Icon +
-

${extensionName}

- ${badgeHtml} +

${ext.name}

+ by ${ext.author || 'Unknown'} +

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

+
+ + ${ext.isInstalled ? 'Installed' : (ext.broken ? 'Broken' : 'Available')} + + ${ext.nsfw ? 'NSFW' : ''} +
${buttonHtml} `; - const installButton = card.querySelector('[data-action="install"]'); - const uninstallButton = card.querySelector('[data-action="uninstall"]'); - - if (installButton) { - installButton.addEventListener('click', async () => { - try { - const response = await fetch('/api/extensions/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName: extension.fileName }), - }); - - const result = await response.json(); - - if (response.ok) { - updateExtensionState(extension.fileName, true); - - await showCustomModal( - 'Installation Successful', - `${extensionName} has been successfully installed.`, - false - ); - } else { - - await showCustomModal( - 'Installation Failed', - `Installation failed: ${result.error || 'Unknown error.'}`, - false - ); - } - } catch (error) { - - await showCustomModal( - 'Installation Failed', - `Network error during installation.`, - false - ); - } - }); + const btn = card.querySelector('.extension-action-button'); + if (!ext.broken || ext.isInstalled) { + btn.onclick = () => ext.isInstalled ? promptUninstall(ext) : handleInstall(ext); } - if (uninstallButton) { - uninstallButton.addEventListener('click', async () => { - - const confirmed = await showCustomModal( - 'Confirm Uninstallation', - `Are you sure you want to uninstall ${extensionName}?`, - true - ); - - if (!confirmed) return; - - try { - const response = await fetch('/api/extensions/uninstall', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName: extension.fileName }), - }); - - const result = await response.json(); - - if (response.ok) { - updateExtensionState(extension.fileName, false); - - await showCustomModal( - 'Uninstallation Successful', - `${extensionName} has been successfully uninstalled.`, - false - ); - } else { - - await showCustomModal( - 'Uninstallation Failed', - `Uninstallation failed: ${result.error || 'Unknown error.'}`, - false - ); - } - } catch (error) { - - await showCustomModal( - 'Uninstallation Failed', - `Network error during uninstallation.`, - false - ); - } - }); - } - - extensionsGrid.appendChild(card); + return card; } -async function getInstalledExtensions() { - console.log(`Fetching installed extensions from: ${INSTALLED_EXTENSIONS_API}`); - - try { - const response = await fetch(INSTALLED_EXTENSIONS_API); - - if (!response.ok) { - console.error(`Error fetching installed extensions. Status: ${response.status}`); - return new Set(); - } - - const data = await response.json(); - - if (!data.extensions || !Array.isArray(data.extensions)) { - console.error("Invalid response format from /api/extensions: 'extensions' array missing or incorrect."); - return new Set(); - } - - const installedFileNames = data.extensions - .map(name => `${name.toLowerCase()}.js`); - - return new Set(installedFileNames); - - } catch (error) { - console.error('Network or JSON parsing error during fetch of installed extensions:', error); - return new Set(); - } -} - -function filterAndRenderExtensions(filterType) { - extensionsGrid.innerHTML = ''; - - if (!allExtensionsData || allExtensionsData.length === 0) { - console.log('No extension data to filter.'); - return; - } - - const filteredExtensions = allExtensionsData.filter(ext => - filterType === 'All' || ext.type === filterType || (ext.isLocal && filterType === 'Local') - ); - - filteredExtensions.forEach(ext => { - renderExtensionCard(ext, ext.isInstalled, ext.isLocal); - }); - - if (filteredExtensions.length === 0) { - extensionsGrid.innerHTML = `

No extensions found for the selected filter (${filterType}).

`; - } -} - -async function loadMarketplace() { - extensionsGrid.innerHTML = ''; - - for (let i = 0; i < 6; i++) { - extensionsGrid.innerHTML += ` -
-
-
-
-
-
-
-
`; - } - - try { - - const [availableExtensionsRaw, installedExtensionsSet] = await Promise.all([ - fetch(API_URL_BASE).then(res => { - if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); - return res.json(); - }), - getInstalledExtensions() - ]); - - const availableExtensionsJs = availableExtensionsRaw.filter(ext => ext.type === 'file' && ext.name.endsWith('.js')); - const detailPromises = []; - - const marketplaceFileNames = new Set(availableExtensionsJs.map(ext => ext.name.toLowerCase())); - - for (const ext of availableExtensionsJs) { - - const downloadUrl = getRawUrl(ext.name); - - const detailsPromise = getExtensionDetails(downloadUrl).then(details => ({ - ...ext, - ...details, - fileName: ext.name, - - isInstalled: installedExtensionsSet.has(ext.name.toLowerCase()), - isLocal: false, - })); - detailPromises.push(detailsPromise); - } - - const extensionsWithDetails = await Promise.all(detailPromises); - - installedExtensionsSet.forEach(installedName => { - - if (!marketplaceFileNames.has(installedName)) { - - const localExt = { - name: formatExtensionName(installedName), - fileName: installedName, - type: 'Local', - isInstalled: true, - isLocal: true, - baseUrl: 'Local Install', - }; - extensionsWithDetails.push(localExt); - } - }); - - extensionsWithDetails.sort((a, b) => { - if (a.isInstalled !== b.isInstalled) { - return b.isInstalled - a.isInstalled; - - } - - const nameA = a.name || ''; - const nameB = b.name || ''; - - return nameA.localeCompare(nameB); - - }); - - allExtensionsData = extensionsWithDetails; - - if (filterSelect) { - filterSelect.addEventListener('change', (event) => { - filterAndRenderExtensions(event.target.value); - }); - } - - filterAndRenderExtensions('All'); - - } catch (error) { - console.error('Error loading the marketplace:', error); - extensionsGrid.innerHTML = ` -
- 🚨 Error loading extensions. -

Could not connect to the extension repository or local endpoint. Detail: ${error.message}

-
- `; - allExtensionsData = []; - } -} - -customModal.addEventListener('click', (e) => { - if (e.target === customModal || e.target.tagName === 'BUTTON') { - customModal.classList.add('hidden'); - } -}); - -document.addEventListener('DOMContentLoaded', loadMarketplace); - -window.addEventListener('scroll', () => { - const navbar = document.getElementById('navbar'); - if (window.scrollY > 0) { - navbar.classList.add('scrolled'); +function showModal(title, message, showConfirm = false, onConfirm = null) { + modalTitle.innerText = title; + modalMessage.innerText = message; + if (showConfirm) { + modalConfirmBtn.classList.remove('hidden'); + modalConfirmBtn.onclick = () => { hideModal(); if (onConfirm) onConfirm(); }; } else { - navbar.classList.remove('scrolled'); + modalConfirmBtn.classList.add('hidden'); } -}); \ No newline at end of file + modalCloseBtn.onclick = hideModal; + modal.classList.remove('hidden'); +} + +function hideModal() { modal.classList.add('hidden'); } + +async function handleInstall(ext) { + try { + const res = await fetch('/api/extensions/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: ext.entry }) + }); + if (res.ok) { + installedExtensions.push(ext.id.toLowerCase()); + renderGroupedView(); + showModal('Success', `${ext.name} installed!`); + } + } catch (e) { showModal('Error', 'Install failed.'); } +} + +function promptUninstall(ext) { + showModal('Confirm', `Uninstall ${ext.name}?`, true, () => handleUninstall(ext)); +} + +async function handleUninstall(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) { + installedExtensions = installedExtensions.filter(id => id !== ext.id.toLowerCase()); + renderGroupedView(); + } + } catch (e) { showModal('Error', 'Uninstall failed.'); } +} + +function showSkeletons() { + marketplaceContent.innerHTML = ` +
+ ${Array(3).fill('
').join('')} +
+ `; +} + +document.addEventListener('DOMContentLoaded', loadMarketplace); \ No newline at end of file diff --git a/desktop/views/css/marketplace.css b/desktop/views/css/marketplace.css index 02d5f7f..b415b38 100644 --- a/desktop/views/css/marketplace.css +++ b/desktop/views/css/marketplace.css @@ -292,4 +292,99 @@ .modal-button.btn-uninstall:hover { background: #ef4444; transform: scale(1.02); +} + +.extension-author { + font-size: 0.8rem; + color: var(--color-text-secondary); + display: block; + margin-bottom: 0.5rem; +} + +.extension-tags { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.badge-available { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.nsfw-ext { + border-color: rgba(220, 38, 38, 0.3); +} + +.broken-ext { + filter: grayscale(0.8); + opacity: 0.7; + border: 1px dashed #ef4444; /* Borde rojo discontinuo */ +} + +.broken-ext:hover { + transform: none; /* Evitamos que se mueva al pasar el ratón si está rota */ +} + +/* Estilos para los Tabs */ +.tabs-container { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(255,255,255,0.1); + padding-bottom: 0.5rem; +} + +.tab-button { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 1.1rem; + font-weight: 700; + padding: 0.5rem 1rem; + cursor: pointer; + transition: all 0.3s; + position: relative; +} + +.tab-button.active { + color: var(--color-primary); +} + +.tab-button.active::after { + content: ''; + position: absolute; + bottom: -0.6rem; + left: 0; + width: 100%; + height: 3px; + background: var(--color-primary); + border-radius: 999px; + box-shadow: 0 0 10px var(--color-primary-glow); +} + +/* Títulos de Secciones en Marketplace */ +.marketplace-section-title { + font-size: 1.4rem; + font-weight: 800; + margin: 2rem 0 1rem 0; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 0.5rem; + text-transform: capitalize; +} + +.marketplace-section-title::before { + content: ''; + display: inline-block; + width: 4px; + height: 20px; + background: var(--color-primary); + border-radius: 2px; +} + +.category-group { + margin-bottom: 3rem; } \ No newline at end of file diff --git a/desktop/views/marketplace.html b/desktop/views/marketplace.html index 69933c6..3a18cf1 100644 --- a/desktop/views/marketplace.html +++ b/desktop/views/marketplace.html @@ -10,13 +10,13 @@ - -
- - WaifuBoard -
+
+
+ + WaifuBoard +
@@ -36,9 +36,9 @@ - + - +