diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index d9594bc..3f1db7e 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -22,6 +22,7 @@ import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView'; import { NitropediaView } from './nitropedia/NitropediaView'; +import { ExternalPluginLoader } from './plugins/ExternalPluginLoader'; import { RightSideView } from './right-side/RightSideView'; import { RoomView } from './room/RoomView'; import { ToolbarView } from './toolbar/ToolbarView'; @@ -118,6 +119,7 @@ export const MainView: FC<{}> = props => + ); }; diff --git a/src/components/plugins/ExternalPluginLoader.tsx b/src/components/plugins/ExternalPluginLoader.tsx new file mode 100644 index 0000000..b97fb41 --- /dev/null +++ b/src/components/plugins/ExternalPluginLoader.tsx @@ -0,0 +1,61 @@ +import { FC, useEffect, useState } from 'react'; +import { GetConfigurationValue } from '../../api'; +import { subscribePlugins } from './NitroPluginApi'; + +// Force the global API to be initialized +import './NitroPluginApi'; + +export const ExternalPluginLoader: FC<{}> = () => +{ + const [, forceUpdate] = useState(0); + + useEffect(() => + { + return subscribePlugins(() => forceUpdate(n => n + 1)); + }, []); + + // MainView only renders after isReady=true in App.tsx, + // so the configuration is guaranteed to be loaded at this point. + useEffect(() => + { + const scripts: HTMLScriptElement[] = []; + + let pluginUrls: string[] = []; + + try + { + pluginUrls = GetConfigurationValue('external.plugins', []); + } + catch (e) + { + console.warn('[NitroPlugins] Could not read external.plugins config:', e); + return; + } + + if (!pluginUrls || pluginUrls.length === 0) + { + console.log('[NitroPlugins] No external plugins configured'); + return; + } + + console.log('[NitroPlugins] Loading external plugins:', pluginUrls); + + for (const url of pluginUrls) + { + const script = document.createElement('script'); + script.src = url; + script.async = true; + script.onload = () => console.log(`[NitroPlugins] Loaded: ${url}`); + script.onerror = () => console.warn(`[NitroPlugins] Failed to load: ${url}`); + document.head.appendChild(script); + scripts.push(script); + } + + return () => + { + scripts.forEach(s => s.remove()); + }; + }, []); + + return null; +}; diff --git a/src/components/plugins/NitroPluginApi.ts b/src/components/plugins/NitroPluginApi.ts new file mode 100644 index 0000000..d52ffa0 --- /dev/null +++ b/src/components/plugins/NitroPluginApi.ts @@ -0,0 +1,193 @@ +import { GetRoomEngine } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, GetRoomSession, SendMessageComposer } from '../../api'; + +/** + * Plugin descriptor registered by external plugin scripts. + */ +export interface INitroPlugin +{ + /** Unique plugin name */ + name: string; + /** Label shown on the button in room tools */ + label: string; + /** CSS class for the icon (nitro-icon class) */ + icon?: string; + /** Called when the plugin button is clicked */ + onOpen: () => void; + /** Called to close/destroy the plugin UI */ + onClose?: () => void; + /** Called when the plugin is first loaded, receives the Nitro API */ + onInit?: (api: INitroPluginApi) => void; +} + +/** + * API exposed to external plugins via window.NitroPlugins + */ +export interface INitroPluginApi +{ + /** Register a plugin */ + register: (plugin: INitroPlugin) => void; + /** Unregister a plugin by name */ + unregister: (name: string) => void; + /** Get all registered plugins */ + getPlugins: () => INitroPlugin[]; + /** Fire a Nitro link event (e.g., 'navigator/toggle-room-info') */ + createLinkEvent: (link: string) => void; + /** Get the room engine instance */ + getRoomEngine: () => ReturnType; + /** Get the current room session */ + getRoomSession: () => ReturnType; + /** Send a message composer to the server */ + sendMessage: typeof SendMessageComposer; + /** Create a draggable floating window and return its container element */ + createWindow: (id: string, title: string, width: number) => HTMLDivElement | null; + /** Destroy a floating window by id */ + destroyWindow: (id: string) => void; +} + +// Internal plugin storage +const _plugins: INitroPlugin[] = []; +const _listeners: Array<() => void> = []; + +function notifyListeners() +{ + _listeners.forEach(fn => fn()); +} + +const pluginApi: INitroPluginApi = { + register(plugin: INitroPlugin) + { + if (_plugins.some(p => p.name === plugin.name)) return; + _plugins.push(plugin); + plugin.onInit?.(pluginApi); + notifyListeners(); + }, + + unregister(name: string) + { + const index = _plugins.findIndex(p => p.name === name); + if (index >= 0) + { + _plugins[index].onClose?.(); + _plugins.splice(index, 1); + notifyListeners(); + } + }, + + getPlugins() + { + return [..._plugins]; + }, + + createLinkEvent(link: string) + { + CreateLinkEvent(link); + }, + + getRoomEngine() + { + return GetRoomEngine(); + }, + + getRoomSession() + { + return GetRoomSession(); + }, + + sendMessage: SendMessageComposer, + + createWindow(id: string, title: string, width: number): HTMLDivElement | null + { + // Remove existing window with same id + pluginApi.destroyWindow(id); + + // Create overlay container + const overlay = document.createElement('div'); + overlay.id = `nitro-plugin-window-${id}`; + overlay.style.cssText = `position:fixed;z-index:500;top:50%;left:50%;transform:translate(-50%,-50%)`; + + // Card wrapper + const card = document.createElement('div'); + card.style.cssText = `width:${width}px;background:#2c3e50;border:1px solid #283F5D;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.5);overflow:hidden;font-family:Ubuntu,sans-serif`; + + // Header (draggable) + const header = document.createElement('div'); + header.style.cssText = `display:flex;align-items:center;justify-content:center;position:relative;min-height:33px;background:linear-gradient(180deg,#3c6a8e 0%,#2a4f6e 100%);cursor:move;user-select:none`; + + const titleEl = document.createElement('span'); + titleEl.textContent = title; + titleEl.style.cssText = `color:#fff;font-size:16px;text-shadow:0 1px 2px rgba(0,0,0,0.5)`; + + const closeBtn = document.createElement('div'); + closeBtn.style.cssText = `position:absolute;right:8px;width:20px;height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;border-radius:50%;background:rgba(255,255,255,0.1)`; + closeBtn.innerHTML = '✕'; + closeBtn.addEventListener('click', () => pluginApi.destroyWindow(id)); + + header.appendChild(titleEl); + header.appendChild(closeBtn); + + // Make draggable + let isDragging = false; + let offsetX = 0, offsetY = 0; + + header.addEventListener('mousedown', (e: MouseEvent) => + { + isDragging = true; + const rect = overlay.getBoundingClientRect(); + offsetX = e.clientX - rect.left; + offsetY = e.clientY - rect.top; + overlay.style.transform = 'none'; + overlay.style.left = rect.left + 'px'; + overlay.style.top = rect.top + 'px'; + }); + + document.addEventListener('mousemove', (e: MouseEvent) => + { + if (!isDragging) return; + overlay.style.left = (e.clientX - offsetX) + 'px'; + overlay.style.top = (e.clientY - offsetY) + 'px'; + }); + + document.addEventListener('mouseup', () => { isDragging = false; }); + + // Content area + const content = document.createElement('div'); + content.style.cssText = `padding:16px`; + + card.appendChild(header); + card.appendChild(content); + overlay.appendChild(card); + document.body.appendChild(overlay); + + return content; + }, + + destroyWindow(id: string) + { + const existing = document.getElementById(`nitro-plugin-window-${id}`); + if (existing) existing.remove(); + } +}; + +/** + * Subscribe to plugin list changes. Returns unsubscribe function. + */ +export function subscribePlugins(listener: () => void): () => void +{ + _listeners.push(listener); + return () => + { + const idx = _listeners.indexOf(listener); + if (idx >= 0) _listeners.splice(idx, 1); + }; +} + +export function getRegisteredPlugins(): INitroPlugin[] +{ + return [..._plugins]; +} + +// Expose globally so external scripts can use it +(window as any).NitroPlugins = pluginApi; + +export { pluginApi };