From a86cd879646d050b222c3e5da7a8b289db2a783e Mon Sep 17 00:00:00 2001 From: Zack3D Date: Sat, 4 Oct 2025 16:40:55 -0700 Subject: [PATCH] feat(plugins): expose plugin routes and load FurryPlace SDK Add setupPluginRoutes to the server and register it in index.ts. Include /furryplace-sdk.js on admin, index and moderation backup pages to enable custom button extensions. --- frontend-backup/admin.html | 3 + frontend-backup/furryplace-sdk.js | 345 ++++++++++++++++++++++ frontend-backup/index.html | 3 + frontend-backup/moderation.html | 3 + frontend-backup/plugins/README.md | 155 ++++++++++ frontend-backup/plugins/example-button.js | 122 ++++++++ scripts/inject-sdk.js | 80 +++++ src/index.ts | 2 + src/routes/plugins.ts | 38 +++ 9 files changed, 751 insertions(+) create mode 100644 frontend-backup/furryplace-sdk.js create mode 100644 frontend-backup/plugins/README.md create mode 100644 frontend-backup/plugins/example-button.js create mode 100644 scripts/inject-sdk.js create mode 100644 src/routes/plugins.ts diff --git a/frontend-backup/admin.html b/frontend-backup/admin.html index 8d1b828..bcf83af 100644 --- a/frontend-backup/admin.html +++ b/frontend-backup/admin.html @@ -88,6 +88,9 @@ href="./img/apple-touch-icon.png" /> + + + diff --git a/frontend-backup/furryplace-sdk.js b/frontend-backup/furryplace-sdk.js new file mode 100644 index 0000000..9fa5a19 --- /dev/null +++ b/frontend-backup/furryplace-sdk.js @@ -0,0 +1,345 @@ +/** + * FurryPlace SDK - Button Extension API + * + * This SDK allows external scripts to add custom buttons to the FurryPlace UI. + * + * @example + * // Register a custom button + * window.FurryPlaceSDK.registerButton({ + * id: 'my-custom-button', + * title: 'My Button', + * icon: '...', + * position: 'before-leaderboard', // or 'after-leaderboard', 'top', 'bottom' + * onClick: (context) => { + * console.log('Button clicked!', context); + * }, + * condition: (context) => { + * // Optional: only show when certain conditions are met + * return context.user?.isLoggedIn; + * } + * }); + */ + +(function() { + 'use strict'; + + const SDK_VERSION = '1.0.0'; + const registeredButtons = []; + let isInitialized = false; + let injectionPoint = null; + + // Find the button container in the DOM + function findButtonContainer() { + // Look for the container with class "flex flex-col items-center gap-3" + const containers = document.querySelectorAll('.flex.flex-col.items-center.gap-3'); + for (const container of containers) { + // Verify it contains the expected buttons by checking for specific classes + const hasExpectedButtons = container.querySelector('.btn.btn-square.shadow-md'); + if (hasExpectedButtons) { + return container; + } + } + return null; + } + + // Wait for the DOM to contain the button container + function waitForButtonContainer(callback, maxAttempts = 50) { + let attempts = 0; + const interval = setInterval(() => { + const container = findButtonContainer(); + if (container) { + clearInterval(interval); + callback(container); + } else if (++attempts >= maxAttempts) { + clearInterval(interval); + console.error('[FurryPlace SDK] Could not find button container after', maxAttempts, 'attempts'); + } + }, 100); + } + + // Create a button element + function createButton(config) { + const wrapper = document.createElement('div'); + wrapper.setAttribute('data-furryplace-button', config.id); + wrapper.className = config.wrapperClass || ''; + + const button = document.createElement('button'); + button.className = config.className || 'btn btn-square shadow-md'; + button.title = config.title || ''; + + if (config.disabled) { + button.disabled = true; + } + + // Add icon + if (config.icon) { + if (typeof config.icon === 'string') { + button.innerHTML = config.icon; + } else if (config.icon instanceof HTMLElement) { + button.appendChild(config.icon); + } + } + + // Add click handler + if (config.onClick) { + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const context = getContext(); + config.onClick(context, e); + }); + } + + wrapper.appendChild(button); + return wrapper; + } + + // Get context information for button callbacks + function getContext() { + return { + sdk: { + version: SDK_VERSION, + }, + user: { + // This would need to be populated from the app's state + // For now, return null - plugins can access app state directly if needed + isLoggedIn: false, + }, + map: { + // Map context if available + } + }; + } + + // Inject buttons into the DOM + function injectButtons(container) { + if (!container) return; + + registeredButtons.forEach(config => { + // Check condition + if (config.condition) { + const context = getContext(); + if (!config.condition(context)) { + return; // Skip this button + } + } + + const button = createButton(config); + + // Determine insertion position + switch (config.position) { + case 'top': + container.insertBefore(button, container.firstChild); + break; + case 'bottom': + container.appendChild(button); + break; + case 'before-leaderboard': { + // Find leaderboard button (has specific SVG viewBox) + const leaderboardBtn = Array.from(container.querySelectorAll('button')).find(btn => { + const svg = btn.querySelector('svg[viewBox="0 -960 960 960"]'); + return svg && svg.querySelector('path[d*="160-200h160v-320"]'); + }); + if (leaderboardBtn && leaderboardBtn.parentElement) { + container.insertBefore(button, leaderboardBtn.parentElement); + } else { + container.appendChild(button); + } + break; + } + case 'after-leaderboard': { + const leaderboardBtn = Array.from(container.querySelectorAll('button')).find(btn => { + const svg = btn.querySelector('svg[viewBox="0 -960 960 960"]'); + return svg && svg.querySelector('path[d*="160-200h160v-320"]'); + }); + if (leaderboardBtn && leaderboardBtn.parentElement && leaderboardBtn.parentElement.nextSibling) { + container.insertBefore(button, leaderboardBtn.parentElement.nextSibling); + } else { + container.appendChild(button); + } + break; + } + default: + container.appendChild(button); + } + }); + + injectionPoint = container; + } + + // Re-inject buttons (useful when the app re-renders) + function reinjectButtons() { + if (!injectionPoint) return; + + // Remove old injected buttons + const oldButtons = injectionPoint.querySelectorAll('[data-furryplace-button]'); + oldButtons.forEach(btn => btn.remove()); + + // Re-inject + injectButtons(injectionPoint); + } + + // Initialize the SDK + function initialize() { + if (isInitialized) { + console.warn('[FurryPlace SDK] Already initialized'); + return; + } + + console.log('[FurryPlace SDK] Initializing v' + SDK_VERSION); + + waitForButtonContainer((container) => { + console.log('[FurryPlace SDK] Found button container, injecting buttons...'); + injectButtons(container); + isInitialized = true; + + // Set up mutation observer to re-inject on DOM changes + const observer = new MutationObserver(() => { + const currentContainer = findButtonContainer(); + if (currentContainer && currentContainer !== injectionPoint) { + // Container changed, re-inject + injectButtons(currentContainer); + } else if (currentContainer && injectionPoint) { + // Check if our buttons are still there + const ourButtons = currentContainer.querySelectorAll('[data-furryplace-button]'); + if (ourButtons.length !== registeredButtons.length) { + reinjectButtons(); + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + }); + } + + // Public API + window.FurryPlaceSDK = { + version: SDK_VERSION, + + /** + * Register a custom button + * @param {Object} config - Button configuration + * @param {string} config.id - Unique identifier for the button + * @param {string} config.title - Tooltip text + * @param {string|HTMLElement} config.icon - SVG string or element + * @param {Function} config.onClick - Click handler (context, event) => {} + * @param {string} [config.position='bottom'] - Where to place the button + * @param {string} [config.className] - Custom CSS classes + * @param {string} [config.wrapperClass] - Wrapper div CSS classes + * @param {Function} [config.condition] - Optional condition (context) => boolean + * @param {boolean} [config.disabled=false] - Whether button is disabled + */ + registerButton(config) { + if (!config.id) { + console.error('[FurryPlace SDK] Button must have an id'); + return; + } + + if (registeredButtons.find(b => b.id === config.id)) { + console.error('[FurryPlace SDK] Button with id "' + config.id + '" already registered'); + return; + } + + registeredButtons.push(config); + console.log('[FurryPlace SDK] Registered button:', config.id); + + // If already initialized, inject immediately + if (isInitialized && injectionPoint) { + const button = createButton(config); + injectionPoint.appendChild(button); + } + }, + + /** + * Unregister a button + * @param {string} id - Button ID to remove + */ + unregisterButton(id) { + const index = registeredButtons.findIndex(b => b.id === id); + if (index !== -1) { + registeredButtons.splice(index, 1); + + // Remove from DOM + if (injectionPoint) { + const element = injectionPoint.querySelector('[data-furryplace-button="' + id + '"]'); + if (element) { + element.remove(); + } + } + + console.log('[FurryPlace SDK] Unregistered button:', id); + } + }, + + /** + * Get list of registered buttons + */ + getButtons() { + return registeredButtons.map(b => ({ id: b.id, title: b.title })); + }, + + /** + * Manually trigger SDK initialization + */ + init() { + initialize(); + }, + + /** + * Get current context + */ + getContext() { + return getContext(); + } + }; + + // Auto-load plugins from server + function autoLoadPlugins() { + console.log('[FurryPlace SDK] Auto-discovering plugins...'); + + fetch('/api/plugins') + .then(response => response.json()) + .then(data => { + if (!data.plugins || data.plugins.length === 0) { + console.log('[FurryPlace SDK] No plugins found'); + return; + } + + console.log('[FurryPlace SDK] Found ' + data.plugins.length + ' plugin(s):', data.plugins.map(p => p.name)); + + // Load each plugin dynamically + data.plugins.forEach(plugin => { + const script = document.createElement('script'); + script.src = plugin.path; + script.async = true; + script.onerror = () => { + console.error('[FurryPlace SDK] Failed to load plugin:', plugin.name); + }; + script.onload = () => { + console.log('[FurryPlace SDK] Loaded plugin:', plugin.name); + }; + document.head.appendChild(script); + }); + }) + .catch(error => { + console.warn('[FurryPlace SDK] Failed to auto-discover plugins:', error.message); + }); + } + + // Auto-initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initialize(); + autoLoadPlugins(); + }); + } else { + initialize(); + autoLoadPlugins(); + } + + console.log('[FurryPlace SDK] Loaded v' + SDK_VERSION); +})(); diff --git a/frontend-backup/index.html b/frontend-backup/index.html index 6e71300..6e91b75 100644 --- a/frontend-backup/index.html +++ b/frontend-backup/index.html @@ -108,6 +108,9 @@ href="./img/apple-touch-icon.png" /> + + + diff --git a/frontend-backup/moderation.html b/frontend-backup/moderation.html index 36cbac7..1376c20 100644 --- a/frontend-backup/moderation.html +++ b/frontend-backup/moderation.html @@ -95,6 +95,9 @@ href="./img/apple-touch-icon.png" /> + + + diff --git a/frontend-backup/plugins/README.md b/frontend-backup/plugins/README.md new file mode 100644 index 0000000..e21da2a --- /dev/null +++ b/frontend-backup/plugins/README.md @@ -0,0 +1,155 @@ +# FurryPlace Plugins + +This directory contains plugins that extend the FurryPlace UI using the FurryPlace SDK. + +## Quick Start + +1. **Create a plugin file** in this directory (e.g., `my-plugin.js`) +2. **Refresh the page** - Plugins are automatically discovered and loaded! +3. **Use the SDK** to register buttons or add other functionality + +> **✨ Auto-Loading:** Plugins are automatically discovered from the `frontend-backup/plugins/` directory. Any `.js` file you add here will be automatically loaded when the page loads. No need to manually edit HTML files! + +## Example Plugin + +See [example-button.js](./example-button.js) for a complete example showing how to: +- Add custom buttons with icons +- Handle click events +- Use conditional rendering +- Apply custom styling +- Open external links + +## FurryPlace SDK API + +### `window.FurryPlaceSDK.registerButton(config)` + +Register a custom button in the UI. + +**Configuration Object:** + +```javascript +{ + id: string, // Required: Unique identifier + title: string, // Required: Tooltip text + icon: string|HTMLElement, // Required: SVG string or DOM element + onClick: Function, // Required: (context, event) => {} + position: string, // Optional: 'top', 'bottom', 'before-leaderboard', 'after-leaderboard' + className: string, // Optional: Custom CSS classes (default: 'btn btn-square shadow-md') + wrapperClass: string, // Optional: Wrapper div CSS classes + condition: Function, // Optional: (context) => boolean + disabled: boolean // Optional: Whether button is disabled +} +``` + +**Positions:** +- `'top'` - First button in the container +- `'bottom'` - Last button in the container +- `'before-leaderboard'` - Before the leaderboard button +- `'after-leaderboard'` - After the leaderboard button (default) + +**Context Object** (passed to `onClick` and `condition`): + +```javascript +{ + sdk: { + version: string // SDK version + }, + user: { + isLoggedIn: boolean // User login status (not yet implemented) + }, + map: {} // Map context (not yet implemented) +} +``` + +### Other SDK Methods + +- `FurryPlaceSDK.unregisterButton(id)` - Remove a registered button +- `FurryPlaceSDK.getButtons()` - Get list of all registered buttons +- `FurryPlaceSDK.getContext()` - Get current context object +- `FurryPlaceSDK.init()` - Manually initialize SDK (usually automatic) + +## Example Usage + +```javascript +(function() { + 'use strict'; + + // Wait for SDK to load + function waitForSDK(callback) { + if (window.FurryPlaceSDK) { + callback(); + } else { + setTimeout(() => waitForSDK(callback), 100); + } + } + + waitForSDK(() => { + // Register a button + window.FurryPlaceSDK.registerButton({ + id: 'my-custom-button', + title: 'My Button', + position: 'bottom', + icon: ` + + + + `, + onClick: (context) => { + alert('Button clicked!'); + console.log('Context:', context); + } + }); + + console.log('[My Plugin] Loaded!'); + }); +})(); +``` + +## Finding SVG Icons + +You can find Material Design icons at: +- https://fonts.google.com/icons + +Copy the SVG code and use it as the `icon` parameter. + +## Tips + +1. **Wrap your plugin in an IIFE** to avoid polluting the global scope +2. **Wait for the SDK** to be available before registering buttons +3. **Use unique IDs** to avoid conflicts with other plugins +4. **Test button positions** to find the best placement for your use case +5. **Check the console** for SDK loading messages and errors + +## How Auto-Loading Works + +The SDK automatically: +1. Calls `/api/plugins` to get a list of all `.js` files in the `plugins/` directory +2. Dynamically loads each plugin by creating ` + */ + +(function() { + 'use strict'; + + // Wait for SDK to be available + function waitForSDK(callback) { + if (window.FurryPlaceSDK) { + callback(); + } else { + setTimeout(() => waitForSDK(callback), 100); + } + } + + // Example 1: Simple button with SVG icon + function registerHelpButton() { + window.FurryPlaceSDK.registerButton({ + id: 'example-help', + title: 'Help & Documentation', + position: 'after-leaderboard', + icon: ` + + + + `, + onClick: (context, event) => { + alert('Help button clicked!\n\nSDK Version: ' + context.sdk.version); + console.log('Context:', context); + } + }); + } + + // Example 2: Button with custom styling + function registerDebugButton() { + window.FurryPlaceSDK.registerButton({ + id: 'example-debug', + title: 'Debug Info', + position: 'bottom', + className: 'btn btn-square btn-accent shadow-md', // Custom styling + icon: ` + + + + `, + onClick: () => { + const info = { + buttons: window.FurryPlaceSDK.getButtons(), + userAgent: navigator.userAgent, + viewport: { + width: window.innerWidth, + height: window.innerHeight + }, + timestamp: new Date().toISOString() + }; + + console.log('Debug Info:', info); + alert('Debug info logged to console'); + } + }); + } + + // Example 3: Conditional button (only shows when user is logged in) + function registerConditionalButton() { + window.FurryPlaceSDK.registerButton({ + id: 'example-conditional', + title: 'Premium Feature', + position: 'before-leaderboard', + icon: ` + + + + `, + onClick: () => { + alert('This is a premium feature!'); + }, + // This condition is just an example - in a real plugin you'd check actual user state + condition: (context) => { + // For now, always show it (change this to check real user state) + return true; + } + }); + } + + // Example 4: Button that opens an external link + function registerDiscordButton() { + window.FurryPlaceSDK.registerButton({ + id: 'example-discord', + title: 'Join our Discord', + position: 'top', + icon: ` + + + + `, + onClick: () => { + window.open('https://discord.gg/ZRC4DnP9Z2', '_blank'); + } + }); + } + + // Initialize plugin + waitForSDK(() => { + console.log('[Example Plugin] Initializing...'); + + // Register all example buttons + registerHelpButton(); + registerDebugButton(); + registerConditionalButton(); + registerDiscordButton(); + + console.log('[Example Plugin] Loaded successfully!'); + console.log('[Example Plugin] Registered buttons:', window.FurryPlaceSDK.getButtons()); + }); +})(); diff --git a/scripts/inject-sdk.js b/scripts/inject-sdk.js new file mode 100644 index 0000000..c71d811 --- /dev/null +++ b/scripts/inject-sdk.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Script to inject the FurryPlace SDK into the frontend HTML files + * + * This modifies the HTML files to include the SDK script tag + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FRONTEND_DIR = path.join(__dirname, '..', 'frontend-backup'); +const SDK_PATH = '/furryplace-sdk.js'; +const HTML_FILES = ['index.html', 'admin.html', 'moderation.html']; + +function injectSDK(htmlPath) { + console.log(`Processing ${htmlPath}...`); + + let content = fs.readFileSync(htmlPath, 'utf8'); + + // Check if SDK is already injected + if (content.includes('furryplace-sdk.js')) { + console.log(` ✓ SDK already injected in ${path.basename(htmlPath)}`); + return; + } + + // Find the closing tag and inject before it + const headCloseTag = ''; + const headCloseIndex = content.indexOf(headCloseTag); + + if (headCloseIndex === -1) { + console.error(` ✗ Could not find tag in ${path.basename(htmlPath)}`); + return; + } + + // Create the SDK script tag + const sdkScript = `\n\t\t\n\t\t\n\t`; + + // Inject the script + content = content.slice(0, headCloseIndex) + sdkScript + content.slice(headCloseIndex); + + // Write back + fs.writeFileSync(htmlPath, content, 'utf8'); + console.log(` ✓ SDK injected into ${path.basename(htmlPath)}`); +} + +function main() { + console.log('FurryPlace SDK Injection Script\n'); + + // Check if SDK file exists + const sdkFilePath = path.join(FRONTEND_DIR, 'furryplace-sdk.js'); + if (!fs.existsSync(sdkFilePath)) { + console.error(`Error: SDK file not found at ${sdkFilePath}`); + process.exit(1); + } + + console.log(`SDK file found: ${sdkFilePath}\n`); + + // Process each HTML file + HTML_FILES.forEach(file => { + const htmlPath = path.join(FRONTEND_DIR, file); + + if (!fs.existsSync(htmlPath)) { + console.warn(`Warning: ${file} not found, skipping...`); + return; + } + + injectSDK(htmlPath); + }); + + console.log('\n✓ SDK injection complete!'); + console.log('\nYou can now create plugins by adding a script tag to your HTML:'); + console.log(' '); +} + +main(); diff --git a/src/index.ts b/src/index.ts index 34d5e29..2e77f8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import pixel from "./routes/pixel.js"; import store from "./routes/store.js"; import { setupSiteContentRoutes } from "./routes/site-content.js"; import { setupRulesRoutes } from "./routes/rules.js"; +import { setupPluginRoutes } from "./routes/plugins.js"; import { addPrismaToRequest } from "./config/database.js"; import fs from "fs/promises"; import path from "path"; @@ -275,6 +276,7 @@ pixel(app); store(app); setupSiteContentRoutes(app); setupRulesRoutes(app); +setupPluginRoutes(app); const PORT = Number(process.env["PORT"]) || 3000; diff --git a/src/routes/plugins.ts b/src/routes/plugins.ts new file mode 100644 index 0000000..ca9fd44 --- /dev/null +++ b/src/routes/plugins.ts @@ -0,0 +1,38 @@ +import type { App } from '@tinyhttp/app'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export function setupPluginRoutes(app: App) { + // Public endpoint - Get list of available plugins + app.get('/api/plugins', async (_req, res) => { + try { + const pluginsDir = path.join(__dirname, '..', '..', 'frontend-backup', 'plugins'); + + // Check if plugins directory exists + if (!fs.existsSync(pluginsDir)) { + return res.json({ plugins: [] }); + } + + // Read all .js files in plugins directory + const files = fs.readdirSync(pluginsDir); + const plugins = files + .filter(file => file.endsWith('.js') && file !== 'README.md') + .map(file => ({ + name: file, + path: `/plugins/${file}`, + size: fs.statSync(path.join(pluginsDir, file)).size, + modified: fs.statSync(path.join(pluginsDir, file)).mtime + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return res.json({ plugins }); + } catch (error) { + console.error('Error listing plugins:', error); + return res.status(500).json({ error: 'Failed to list plugins', status: 500 }); + } + }); +}