mirror of
https://github.com/unraid/api.git
synced 2026-04-30 04:01:10 -05:00
88087d5201
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Standalone web bundle with auto-mount utilities and a self-contained test page. * New responsive modal components for consistent mobile/desktop dialogs. * Header actions to copy OS/API versions. * **Improvements** * Refreshed UI styles (muted borders), accessibility and animation refinements. * Theming updates and Tailwind v4–aligned, component-scoped styles. * Runtime GraphQL endpoint override and CSRF header support. * **Bug Fixes** * Safer network fetching and improved manifest/asset loading with duplicate protection. * **Tests/Chores** * Parallel plugin tests, new extractor test suite, and updated build/test scripts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
286 lines
8.9 KiB
TypeScript
286 lines
8.9 KiB
TypeScript
import { createApp } from 'vue';
|
|
import type { App as VueApp, Component } from 'vue';
|
|
import { createI18n } from 'vue-i18n';
|
|
import { DefaultApolloClient } from '@vue/apollo-composable';
|
|
import { ensureTeleportContainer } from '@unraid/ui';
|
|
|
|
// Import Tailwind CSS for injection
|
|
import tailwindStyles from '~/assets/main.css?inline';
|
|
|
|
import en_US from '~/locales/en_US.json';
|
|
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
|
import { globalPinia } from '~/store/globalPinia';
|
|
import { client } from '~/helpers/create-apollo-client';
|
|
|
|
// Ensure Apollo client is singleton
|
|
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
|
|
|
|
// Global store for mounted apps
|
|
const mountedApps = new Map<string, VueApp>();
|
|
const mountedAppClones = new Map<string, VueApp[]>();
|
|
const mountedAppContainers = new Map<string, HTMLElement[]>(); // shadow-root containers for cleanup
|
|
|
|
// Shared style injection tracking
|
|
const styleInjected = new WeakSet<Document | ShadowRoot>();
|
|
|
|
// Expose globally for debugging
|
|
declare global {
|
|
interface Window {
|
|
mountedApps: Map<string, VueApp>;
|
|
globalPinia: typeof globalPinia;
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.mountedApps = mountedApps;
|
|
window.globalPinia = globalPinia;
|
|
}
|
|
|
|
function injectStyles(root: Document | ShadowRoot) {
|
|
// Always inject to document for teleported elements
|
|
if (!styleInjected.has(document)) {
|
|
const globalStyleElement = document.createElement('style');
|
|
globalStyleElement.setAttribute('data-tailwind-global', 'true');
|
|
globalStyleElement.textContent = tailwindStyles;
|
|
document.head.appendChild(globalStyleElement);
|
|
styleInjected.add(document);
|
|
}
|
|
|
|
// Also inject to shadow root if needed
|
|
if (root !== document && !styleInjected.has(root)) {
|
|
const styleElement = document.createElement('style');
|
|
styleElement.setAttribute('data-tailwind', 'true');
|
|
styleElement.textContent = tailwindStyles;
|
|
root.appendChild(styleElement);
|
|
styleInjected.add(root);
|
|
}
|
|
}
|
|
|
|
function setupI18n() {
|
|
const defaultLocale = 'en_US';
|
|
let parsedLocale = '';
|
|
let parsedMessages = {};
|
|
let nonDefaultLocale = false;
|
|
|
|
// Check for window locale data
|
|
if (typeof window !== 'undefined') {
|
|
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
|
|
if (windowLocaleData) {
|
|
try {
|
|
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
|
|
parsedLocale = Object.keys(parsedMessages)[0];
|
|
nonDefaultLocale = parsedLocale !== defaultLocale;
|
|
} catch (error) {
|
|
console.error('[VueMountApp] error parsing messages', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return createI18n({
|
|
legacy: false,
|
|
locale: nonDefaultLocale ? parsedLocale : defaultLocale,
|
|
fallbackLocale: defaultLocale,
|
|
messages: {
|
|
en_US,
|
|
...(nonDefaultLocale ? parsedMessages : {}),
|
|
},
|
|
postTranslation: createHtmlEntityDecoder(),
|
|
});
|
|
}
|
|
|
|
export interface MountOptions {
|
|
component: Component;
|
|
selector: string;
|
|
appId?: string;
|
|
useShadowRoot?: boolean;
|
|
props?: Record<string, unknown>;
|
|
}
|
|
|
|
// Helper function to parse props from HTML attributes
|
|
function parsePropsFromElement(element: Element): Record<string, unknown> {
|
|
const props: Record<string, unknown> = {};
|
|
|
|
for (const attr of element.attributes) {
|
|
const name = attr.name;
|
|
const value = attr.value;
|
|
|
|
// Skip Vue internal attributes and common HTML attributes
|
|
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
|
|
continue;
|
|
}
|
|
|
|
// Try to parse JSON values (handles HTML-encoded JSON)
|
|
if (value.startsWith('{') || value.startsWith('[')) {
|
|
try {
|
|
// Decode HTML entities first
|
|
const decoded = value
|
|
.replace(/"/g, '"')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/'/g, "'");
|
|
props[name] = JSON.parse(decoded);
|
|
} catch (_e) {
|
|
// If JSON parsing fails, use as string
|
|
props[name] = value;
|
|
}
|
|
} else {
|
|
props[name] = value;
|
|
}
|
|
}
|
|
|
|
return props;
|
|
}
|
|
|
|
export function mountVueApp(options: MountOptions): VueApp | null {
|
|
const { component, selector, appId = selector, useShadowRoot = false, props = {} } = options;
|
|
|
|
// Check if app is already mounted
|
|
if (mountedApps.has(appId)) {
|
|
console.warn(`[VueMountApp] App ${appId} is already mounted`);
|
|
return mountedApps.get(appId)!;
|
|
}
|
|
|
|
// Find all mount targets
|
|
const targets = document.querySelectorAll(selector);
|
|
if (targets.length === 0) {
|
|
console.warn(`[VueMountApp] No elements found for selector: ${selector}`);
|
|
return null;
|
|
}
|
|
|
|
// Ensure teleport container exists before mounting
|
|
ensureTeleportContainer();
|
|
|
|
// For the first target, parse props from HTML attributes
|
|
const firstTarget = targets[0];
|
|
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
|
|
|
|
// Create the Vue app with parsed props
|
|
const app = createApp(component, parsedProps);
|
|
|
|
// Setup i18n
|
|
const i18n = setupI18n();
|
|
app.use(i18n);
|
|
|
|
// Use the shared Pinia instance
|
|
app.use(globalPinia);
|
|
|
|
// Provide Apollo client
|
|
app.provide(DefaultApolloClient, apolloClient);
|
|
|
|
// Mount to all targets
|
|
const clones: VueApp[] = [];
|
|
const containers: HTMLElement[] = [];
|
|
targets.forEach((target, index) => {
|
|
const mountTarget = target as HTMLElement;
|
|
|
|
// Add unapi class for minimal styling
|
|
mountTarget.classList.add('unapi');
|
|
|
|
if (useShadowRoot) {
|
|
// Create shadow root if needed
|
|
if (!mountTarget.shadowRoot) {
|
|
mountTarget.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
// Create mount container in shadow root
|
|
const container = document.createElement('div');
|
|
container.id = 'app';
|
|
container.setAttribute('data-app-id', appId);
|
|
mountTarget.shadowRoot!.appendChild(container);
|
|
containers.push(container);
|
|
|
|
// Inject styles into shadow root
|
|
injectStyles(mountTarget.shadowRoot!);
|
|
|
|
// For the first target, use the main app, otherwise create clones
|
|
if (index === 0) {
|
|
app.mount(container);
|
|
} else {
|
|
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
|
const clonedApp = createApp(component, targetProps);
|
|
clonedApp.use(i18n);
|
|
clonedApp.use(globalPinia);
|
|
clonedApp.provide(DefaultApolloClient, apolloClient);
|
|
clonedApp.mount(container);
|
|
clones.push(clonedApp);
|
|
}
|
|
} else {
|
|
// Direct mount without shadow root
|
|
injectStyles(document);
|
|
|
|
// For multiple targets, we need to create separate app instances
|
|
// but they'll share the same Pinia store
|
|
if (index === 0) {
|
|
// First target, use the main app
|
|
app.mount(mountTarget);
|
|
} else {
|
|
// Additional targets, create cloned apps with their own props
|
|
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
|
const clonedApp = createApp(component, targetProps);
|
|
clonedApp.use(i18n);
|
|
clonedApp.use(globalPinia); // Shared Pinia instance
|
|
clonedApp.provide(DefaultApolloClient, apolloClient);
|
|
clonedApp.mount(mountTarget);
|
|
clones.push(clonedApp);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Store the app reference
|
|
mountedApps.set(appId, app);
|
|
if (clones.length) mountedAppClones.set(appId, clones);
|
|
if (containers.length) mountedAppContainers.set(appId, containers);
|
|
|
|
return app;
|
|
}
|
|
|
|
export function unmountVueApp(appId: string): boolean {
|
|
const app = mountedApps.get(appId);
|
|
if (!app) {
|
|
console.warn(`[VueMountApp] No app found with id: ${appId}`);
|
|
return false;
|
|
}
|
|
|
|
// Unmount clones first
|
|
const clones = mountedAppClones.get(appId) ?? [];
|
|
for (const c of clones) c.unmount();
|
|
mountedAppClones.delete(appId);
|
|
|
|
// Remove shadow containers
|
|
const containers = mountedAppContainers.get(appId) ?? [];
|
|
for (const el of containers) el.remove();
|
|
mountedAppContainers.delete(appId);
|
|
|
|
app.unmount();
|
|
mountedApps.delete(appId);
|
|
return true;
|
|
}
|
|
|
|
export function getMountedApp(appId: string): VueApp | undefined {
|
|
return mountedApps.get(appId);
|
|
}
|
|
|
|
// Auto-mount function for script tags
|
|
export function autoMountComponent(component: Component, selector: string, options?: Partial<MountOptions>) {
|
|
const tryMount = () => {
|
|
// Check if elements exist before attempting to mount
|
|
if (document.querySelector(selector)) {
|
|
try {
|
|
mountVueApp({ component, selector, ...options });
|
|
} catch (error) {
|
|
console.error(`[VueMountApp] Failed to mount component for selector ${selector}:`, error);
|
|
}
|
|
}
|
|
// Silently skip if no elements found - this is expected for most components
|
|
};
|
|
|
|
// Wait for DOM to be ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', tryMount);
|
|
} else {
|
|
// DOM is already ready, but use setTimeout to ensure all scripts are loaded
|
|
setTimeout(tryMount, 0);
|
|
}
|
|
}
|