diff --git a/src/api/extensions/extensions.controller.ts b/src/api/extensions/extensions.controller.ts
index 93f615b..6b97931 100644
--- a/src/api/extensions/extensions.controller.ts
+++ b/src/api/extensions/extensions.controller.ts
@@ -1,5 +1,5 @@
import { FastifyReply, FastifyRequest } from 'fastify';
-import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap } from '../../shared/extensions';
+import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap, saveExtensionFile, deleteExtensionFile } from '../../shared/extensions';
import { ExtensionNameRequest } from '../types';
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
@@ -34,4 +34,52 @@ export async function getExtensionSettings(req: ExtensionNameRequest, reply: Fas
}
return ext.getSettings();
+}
+
+export async function installExtension(req: any, reply: FastifyReply) {
+ const { fileName } = req.body;
+
+ if (!fileName || !fileName.endsWith('.js')) {
+ return reply.code(400).send({ error: "Invalid extension fileName provided" });
+ }
+
+ try {
+
+ const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}`
+
+ await saveExtensionFile(fileName, downloadUrl);
+
+ req.server.log.info(`Extension installed: ${fileName}`);
+ 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}.` });
+ }
+}
+
+export async function uninstallExtension(req: any, reply: FastifyReply) {
+ const { fileName } = req.body;
+
+ if (!fileName || !fileName.endsWith('.js')) {
+ return reply.code(400).send({ error: "Invalid extension fileName provided" });
+ }
+
+ try {
+
+ await deleteExtensionFile(fileName);
+
+ req.server.log.info(`Extension uninstalled: ${fileName}`);
+ return reply.code(200).send({ success: true, message: `Extension ${fileName} uninstalled successfully.` });
+
+ } catch (error) {
+
+ // @ts-ignore
+ if (error.code === 'ENOENT') {
+ return reply.code(200).send({ success: true, message: `Extension ${fileName} already uninstalled (file not found).` });
+ }
+
+ req.server.log.error(`Failed to uninstall extension ${fileName}:`, error);
+ return reply.code(500).send({ success: false, error: `Failed to uninstall extension ${fileName}.` });
+ }
}
\ No newline at end of file
diff --git a/src/api/extensions/extensions.routes.ts b/src/api/extensions/extensions.routes.ts
index 6a065fe..fd8e0c2 100644
--- a/src/api/extensions/extensions.routes.ts
+++ b/src/api/extensions/extensions.routes.ts
@@ -7,6 +7,8 @@ async function extensionsRoutes(fastify: FastifyInstance) {
fastify.get('/extensions/book', controller.getBookExtensions);
fastify.get('/extensions/gallery', controller.getGalleryExtensions);
fastify.get('/extensions/:name/settings', controller.getExtensionSettings);
+ fastify.post('/extensions/install', controller.installExtension);
+ fastify.post('/extensions/uninstall', controller.uninstallExtension);
}
export default extensionsRoutes;
\ No newline at end of file
diff --git a/src/scripts/marketplace.js b/src/scripts/marketplace.js
new file mode 100644
index 0000000..6180eb9
--- /dev/null
+++ b/src/scripts/marketplace.js
@@ -0,0 +1,422 @@
+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 INSTALLED_EXTENSIONS_API = '/api/extensions';
+
+const extensionsGrid = document.getElementById('extensions-grid');
+const filterSelect = document.getElementById('extension-filter');
+
+let allExtensionsData = [];
+
+const customModal = document.getElementById('customModal');
+const modalTitle = document.getElementById('modalTitle');
+const modalMessage = document.getElementById('modalMessage');
+
+function getRawUrl(filename) {
+
+ 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) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
+ const text = await res.text();
+
+ 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}`);
+ }
+ }
+
+ 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' };
+ }
+}
+
+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);
+ };
+
+ newConfirmButton.onclick = () => closeModal(true);
+ newCloseButton.onclick = () => closeModal(false);
+
+ customModal.classList.remove('hidden');
+ });
+}
+
+function renderExtensionCard(extension, isInstalled, isLocalOnly = false) {
+
+ const extensionName = formatExtensionName(extension.fileName || extension.name);
+ const extensionType = extension.type || 'Unknown';
+
+ let iconUrl;
+
+ if (extension.baseUrl && extension.baseUrl !== 'Local Install') {
+
+ iconUrl = `https://www.google.com/s2/favicons?domain=${extension.baseUrl}&sz=128`;
+ } else {
+
+ const displayName = extensionName.replace(/\s/g, '+');
+ iconUrl = `https://ui-avatars.com/api/?name=${displayName}&background=1f2937&color=fff&length=1`;
+ }
+
+ 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;
+
+ let buttonHtml;
+ let badgeHtml = '';
+
+ if (isInstalled) {
+
+ if (isLocalOnly) {
+ badgeHtml = 'Local';
+ } else {
+ badgeHtml = 'Installed';
+ }
+ buttonHtml = `
+
+ `;
+ } else {
+
+ buttonHtml = `
+
+ `;
+ }
+
+ card.innerHTML = `
+
+
+
${extensionName}
+ ${badgeHtml}
+
+ ${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
+ );
+ }
+ });
+ }
+
+ 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);
+}
+
+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');
+ } else {
+ navbar.classList.remove('scrolled');
+ }
+});
\ No newline at end of file
diff --git a/src/shared/extensions.js b/src/shared/extensions.js
index 407b29e..2fce8f5 100644
--- a/src/shared/extensions.js
+++ b/src/shared/extensions.js
@@ -20,30 +20,7 @@ async function loadExtensions() {
for (const file of files) {
if (file.endsWith('.js')) {
- const filePath = path.join(extensionsDir, file);
-
- try {
- delete require.cache[require.resolve(filePath)];
-
- const ExtensionClass = require(filePath);
-
- const instance = typeof ExtensionClass === 'function'
- ? new ExtensionClass()
- : (ExtensionClass.default ? new ExtensionClass.default() : null);
-
- if (instance &&
- (instance.type === "anime-board" ||
- instance.type === "book-board" ||
- instance.type === "image-board")) {
-
- const name = instance.constructor.name;
- extensions.set(name, instance);
- instance.scrape = scrape;
- console.log(`📦 Loaded Extension: ${name}`);
- }
- } catch (e) {
- console.error(`❌ Failed to load extension ${file}:`, e.message);
- }
+ await loadExtension(file);
}
}
@@ -51,7 +28,6 @@ async function loadExtensions() {
try {
const loaded = Array.from(extensions.keys());
-
const rows = await queryAll("SELECT DISTINCT ext_name FROM extension");
for (const row of rows) {
@@ -69,6 +45,114 @@ async function loadExtensions() {
}
}
+
+async function loadExtension(fileName) {
+ const homeDir = os.homedir();
+ const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
+ const filePath = path.join(extensionsDir, fileName);
+
+ if (!fs.existsSync(filePath)) {
+ throw new Error("Extension file does not exist");
+ }
+
+ try {
+ delete require.cache[require.resolve(filePath)];
+
+ const ExtensionClass = require(filePath);
+
+ const instance = typeof ExtensionClass === 'function'
+ ? new ExtensionClass()
+ : (ExtensionClass.default ? new ExtensionClass.default() : null);
+
+ if (!instance) {
+ throw new Error("Invalid extension export");
+ }
+
+ if (
+ instance.type !== "anime-board" &&
+ instance.type !== "book-board" &&
+ instance.type !== "image-board"
+ ) {
+ throw new Error(`Invalid extension type: ${instance.type}`);
+ }
+
+ const name = instance.constructor.name;
+ instance.scrape = scrape;
+ extensions.set(name, instance);
+
+ console.log(`📦 Installed & Loaded Extension: ${name}`);
+ return name;
+
+ } catch (err) {
+ throw new Error(`LoadExtension failed: ${err.message}`);
+ }
+}
+
+const https = require('https');
+
+async function saveExtensionFile(fileName, downloadUrl) {
+ const homeDir = os.homedir();
+ const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
+ const filePath = path.join(extensionsDir, fileName);
+ const fullUrl = downloadUrl;
+
+ if (!fs.existsSync(extensionsDir)) {
+ fs.mkdirSync(extensionsDir, { recursive: true });
+ }
+
+ return new Promise((resolve, reject) => {
+ const file = fs.createWriteStream(filePath);
+
+ https.get(fullUrl, async (response) => {
+ if (response.statusCode !== 200) {
+ return reject(new Error(`Download failed: ${response.statusCode}`));
+ }
+
+ response.pipe(file);
+
+ file.on('finish', async () => {
+ file.close(async () => {
+ try {
+ await loadExtension(fileName);
+ resolve();
+ } catch (err) {
+ if (fs.existsSync(filePath)) {
+ await fs.promises.unlink(filePath);
+ }
+ reject(new Error(`Load failed, file rolled back: ${err.message}`));
+ }
+ });
+ });
+ }).on('error', async (err) => {
+ if (fs.existsSync(filePath)) {
+ await fs.promises.unlink(filePath);
+ }
+ reject(err);
+ });
+ });
+}
+
+async function deleteExtensionFile(fileName) {
+ const homeDir = os.homedir();
+ const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
+ const filePath = path.join(extensionsDir, fileName);
+
+ const extName = fileName.replace(".js", "");
+
+ for (const key of extensions.keys()) {
+ if (key.toLowerCase() === extName) {
+ extensions.delete(key);
+ console.log(`🗑️ Removed from memory: ${key}`);
+ break;
+ }
+ }
+
+ if (fs.existsSync(filePath)) {
+ await fs.promises.unlink(filePath);
+ console.log(`🗑️ Deleted file: ${fileName}`);
+ }
+}
+
function getExtension(name) {
return extensions.get(name);
}
@@ -118,5 +202,7 @@ module.exports = {
getExtensionsList,
getAnimeExtensionsMap,
getBookExtensionsMap,
- getGalleryExtensionsMap
+ getGalleryExtensionsMap,
+ saveExtensionFile,
+ deleteExtensionFile
};
\ No newline at end of file
diff --git a/src/views/views.routes.ts b/src/views/views.routes.ts
index 1566774..d277397 100644
--- a/src/views/views.routes.ts
+++ b/src/views/views.routes.ts
@@ -24,6 +24,11 @@ async function viewsRoutes(fastify: FastifyInstance) {
reply.type('text/html').send(stream);
});
+ fastify.get('/marketplace', (req: FastifyRequest, reply: FastifyReply) => {
+ const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html'));
+ reply.type('text/html').send(stream);
+ });
+
fastify.get('/gallery/:extension/*', (req: FastifyRequest, reply: FastifyReply) => {
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html'));
reply.type('text/html').send(stream);
diff --git a/views/css/marketplace.css b/views/css/marketplace.css
new file mode 100644
index 0000000..37793bd
--- /dev/null
+++ b/views/css/marketplace.css
@@ -0,0 +1,308 @@
+.hero-spacer {
+
+ height: var(--nav-height);
+ width: 100%;
+}
+
+.marketplace-subtitle {
+ font-size: 1.1rem;
+ color: var(--text-secondary);
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.filter-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.filter-label {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.filter-select {
+
+ padding: 0.6rem 2rem 0.6rem 1.25rem;
+ border-radius: 999px;
+ background: var(--bg-surface-hover);
+ color: var(--text-primary);
+ border: 1px solid rgba(255,255,255,0.1);
+ appearance: none;
+ font-weight: 600;
+
+ background-image: url('data:image/svg+xml;utf8,');
+ background-repeat: no-repeat;
+ background-position: right 0.75rem center;
+ background-size: 1em;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.filter-select:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 8px var(--accent-glow);
+}
+
+.marketplace-grid {
+
+ display: grid;
+
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 1rem;
+ padding-top: 1rem;
+}
+
+.extension-card {
+ background: var(--bg-surface);
+ border: 1px solid rgba(255,255,255,0.05);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ transition: all 0.2s;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.3);
+ min-height: 140px;
+ position: relative;
+ overflow: hidden;
+}
+
+.extension-card:hover {
+ background: var(--bg-surface-hover);
+ transform: translateY(-4px);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.5);
+}
+
+.card-content-wrapper {
+ flex-grow: 1;
+ margin-bottom: 0.5rem;
+}
+
+.extension-icon {
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ object-fit: contain;
+ margin-bottom: 0.75rem;
+ border: 2px solid var(--accent);
+ background-color: var(--bg-base);
+ flex-shrink: 0;
+ box-shadow: 0 0 10px var(--accent-glow);
+}
+
+.extension-name {
+ font-size: 1.1rem;
+ font-weight: 700;
+ margin: 0;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+}
+
+.extension-status-badge {
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ margin-top: 0.4rem;
+ display: inline-block;
+ letter-spacing: 0.5px;
+}
+
+.badge-installed {
+ background: rgba(34, 197, 94, 0.2);
+ color: #4ade80;
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.badge-local {
+ background: rgba(251, 191, 36, 0.2);
+ color: #fcd34d;
+ border: 1px solid rgba(251, 191, 36, 0.3);
+}
+
+.extension-action-button {
+ width: 100%;
+ padding: 0.6rem 1rem;
+ border-radius: 999px;
+ font-weight: 700;
+ font-size: 0.9rem;
+ border: none;
+ cursor: pointer;
+ transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
+ margin-top: auto;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.btn-install {
+ background: var(--accent);
+ color: white;
+}
+.btn-install:hover {
+ background: #a78bfa;
+ transform: scale(1.02);
+ box-shadow: 0 0 15px var(--accent-glow);
+}
+
+.btn-uninstall {
+ background: #dc2626;
+ color: white;
+ margin-top: auto;
+}
+
+.btn-uninstall:hover {
+ background: #ef4444;
+ transform: scale(1.02);
+ box-shadow: 0 0 15px rgba(220, 38, 38, 0.4);
+}
+
+.extension-card.skeleton {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: space-between;
+
+ box-shadow: none;
+ transform: none;
+}
+.extension-card.skeleton:hover {
+ background: var(--bg-surface);
+ box-shadow: none;
+ transform: none;
+}
+.skeleton-icon {
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ margin-bottom: 0.75rem;
+
+ border: 2px solid rgba(255,255,255,0.05);
+}
+.skeleton-text.title-skeleton {
+ height: 1.1em;
+ margin-bottom: 0.25rem;
+}
+.skeleton-text.text-skeleton {
+ height: 0.7em;
+ margin-bottom: 0;
+}
+.skeleton-button {
+ width: 100%;
+ height: 32px;
+ border-radius: 999px;
+ margin-top: auto;
+}
+
+.section-title {
+ font-size: 1.8rem;
+ font-weight: 800;
+ color: var(--text-primary);
+}
+
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(5px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 5000;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease-in-out;
+}
+
+.modal-overlay:not(.hidden) {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.modal-content {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ padding: 2.5rem;
+ border-radius: var(--radius-lg);
+ width: 90%;
+ max-width: 450px;
+ box-shadow: 0 15px 50px rgba(0, 0, 0, 0.8), 0 0 20px var(--accent-glow);
+ border: 1px solid rgba(255,255,255,0.1);
+ transform: translateY(-50px);
+ transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease-in-out;
+ opacity: 0;
+}
+
+.modal-overlay:not(.hidden) .modal-content {
+ transform: translateY(0);
+ opacity: 1;
+}
+
+#modalTitle {
+ font-size: 1.6rem;
+ font-weight: 800;
+ margin-top: 0;
+ color: var(--accent);
+ border-bottom: 2px solid rgba(255,255,255,0.05);
+ padding-bottom: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+#modalMessage {
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 2rem;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+.modal-button {
+
+ padding: 0.6rem 1.5rem;
+ border-radius: 999px;
+ font-weight: 700;
+ font-size: 0.9rem;
+ border: none;
+ cursor: pointer;
+ transition: background 0.2s, transform 0.2s;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.modal-button.btn-install {
+ background: var(--accent);
+ color: white;
+}
+.modal-button.btn-install:hover {
+ background: #a78bfa;
+ transform: scale(1.02);
+}
+
+.modal-button.btn-uninstall {
+ background: #dc2626;
+ color: white;
+}
+.modal-button.btn-uninstall:hover {
+ background: #ef4444;
+ transform: scale(1.02);
+}
\ No newline at end of file
diff --git a/views/marketplace.html b/views/marketplace.html
new file mode 100644
index 0000000..e847000
--- /dev/null
+++ b/views/marketplace.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+ WaifuBoard - Marketplace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file