feat: mount vue apps, not web components

This commit is contained in:
Eli Bosley
2025-08-30 20:49:31 -04:00
parent 6356f9c41d
commit 85b250eb80
15 changed files with 939 additions and 89 deletions

View File

@@ -1798,6 +1798,8 @@ type Server implements Node {
guid: String!
apikey: String!
name: String!
"""Whether this server is online or offline"""
status: ServerStatus!
wanip: String!
lanip: String!

View File

@@ -8,6 +8,7 @@ class WebComponentsExtractor
private const RICH_COMPONENTS_ENTRY_JS = 'unraid-components.client.js';
private const UI_ENTRY = 'src/register.ts';
private const UI_STYLES_ENTRY = 'style.css';
private const STANDALONE_APPS_ENTRY = 'standalone-apps.js';
private static ?WebComponentsExtractor $instance = null;
@@ -98,6 +99,41 @@ class WebComponentsExtractor
</script>';
}
private function getStandaloneAppsScript(): string
{
$manifestFiles = $this->findManifestFiles('standalone.manifest.json');
if (empty($manifestFiles)) {
// No standalone apps, return empty
return '';
}
$manifestPath = $manifestFiles[0];
$manifest = $this->getManifestContents($manifestPath);
$subfolder = $this->getRelativePath($manifestPath);
if (!isset($manifest[self::STANDALONE_APPS_ENTRY])) {
return '';
}
$jsFile = ($subfolder ? $subfolder . '/' : '') . $manifest[self::STANDALONE_APPS_ENTRY]['file'];
// Use a unique identifier to prevent duplicate script loading
$scriptId = 'unraid-standalone-apps-script';
return '<script id="' . $scriptId . '" type="module" src="' . $this->getAssetPath($jsFile) . '"></script>
<script>
// Remove duplicate script tags to prevent multiple loads
(function() {
var scripts = document.querySelectorAll(\'script[id="' . $scriptId . '"]\');
if (scripts.length > 1) {
for (var i = 1; i < scripts.length; i++) {
scripts[i].remove();
}
}
})();
</script>';
}
private function getUnraidUiScriptHtml(): string
{
$manifestFiles = $this->findManifestFiles('ui.manifest.json');
@@ -173,7 +209,9 @@ class WebComponentsExtractor
try {
$scriptsOutput = true;
return $this->getRichComponentsScript() . $this->getUnraidUiScriptHtml();
return $this->getRichComponentsScript() .
$this->getUnraidUiScriptHtml() .
$this->getStandaloneAppsScript();
} catch (\Exception $e) {
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
$scriptsOutput = false; // Reset on error

View File

@@ -0,0 +1,199 @@
import { createApp } from 'vue';
import type { App as VueApp, Component } from 'vue';
import { createI18n } from 'vue-i18n';
import { DefaultApolloClient } from '@vue/apollo-composable';
// 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';
// Global store for mounted apps
const mountedApps = new Map<string, VueApp>();
// 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>;
}
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;
}
// Create the Vue app
const app = createApp(component, props);
// Setup i18n
const i18n = setupI18n();
app.use(i18n);
// Use the shared Pinia instance
app.use(globalPinia);
// Provide Apollo client
app.provide(DefaultApolloClient, client);
// Mount to all targets
targets.forEach((target) => {
const mountTarget = target as HTMLElement;
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';
mountTarget.shadowRoot!.appendChild(container);
// Inject styles into shadow root
injectStyles(mountTarget.shadowRoot!);
// Clone and mount the app to this container
const clonedApp = createApp(component, props);
clonedApp.use(i18n);
clonedApp.use(globalPinia);
clonedApp.provide(DefaultApolloClient, client);
clonedApp.mount(container);
} 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 (Array.from(targets).indexOf(mountTarget) === 0) {
// First target, use the main app
app.mount(mountTarget);
} else {
// Additional targets, create cloned apps
const clonedApp = createApp(component, props);
clonedApp.use(i18n);
clonedApp.use(globalPinia); // Shared Pinia instance
clonedApp.provide(DefaultApolloClient, client);
clonedApp.mount(mountTarget);
}
}
});
// Store the app reference
mountedApps.set(appId, app);
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;
}
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>) {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
mountVueApp({ component, selector, ...options });
});
} else {
// DOM is already ready
mountVueApp({ component, selector, ...options });
}
}

View File

@@ -0,0 +1,123 @@
// Import all components
import type { Component } from 'vue';
import Auth from './Auth.ce.vue';
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
import HeaderOsVersion from './HeaderOsVersion.ce.vue';
import Modals from './Modals.ce.vue';
import UserProfile from './UserProfile.ce.vue';
import UpdateOs from './UpdateOs.ce.vue';
import DowngradeOs from './DowngradeOs.ce.vue';
import Registration from './Registration.ce.vue';
import WanIpCheck from './WanIpCheck.ce.vue';
import WelcomeModal from './Activation/WelcomeModal.ce.vue';
import SsoButton from './SsoButton.ce.vue';
import LogViewer from './Logs/LogViewer.ce.vue';
import ThemeSwitcher from './ThemeSwitcher.ce.vue';
import ApiKeyPage from './ApiKeyPage.ce.vue';
import DevModalTest from './DevModalTest.ce.vue';
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
// Import utilities
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
import { useThemeStore } from '~/store/theme';
import { globalPinia } from '~/store/globalPinia';
// Initialize theme store and set CSS variables
if (typeof window !== 'undefined') {
// Ensure pinia is ready
const themeStore = useThemeStore(globalPinia);
themeStore.setTheme();
themeStore.setCssVars();
}
// Define component mappings
const componentMappings = [
{ component: Auth, selector: 'unraid-auth', appId: 'auth' },
{ component: ConnectSettings, selector: 'unraid-connect-settings', appId: 'connect-settings' },
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
{ component: Registration, selector: 'unraid-registration', appId: 'registration' },
{ component: WanIpCheck, selector: 'unraid-wan-ip-check', appId: 'wan-ip-check' },
{ component: WelcomeModal, selector: 'unraid-welcome-modal', appId: 'welcome-modal' },
{ component: SsoButton, selector: 'unraid-sso-button', appId: 'sso-button' },
{ component: LogViewer, selector: 'unraid-log-viewer', appId: 'log-viewer' },
{ component: ThemeSwitcher, selector: 'unraid-theme-switcher', appId: 'theme-switcher' },
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
];
// Auto-mount all components
componentMappings.forEach(({ component, selector, appId }) => {
autoMountComponent(component, selector, {
appId,
useShadowRoot: false, // Mount directly to avoid shadow DOM issues
});
});
// Special handling for Modals - also mount to #modals if it exists
if (document.querySelector('#modals')) {
mountVueApp({
component: Modals,
selector: '#modals',
appId: 'modals-direct',
useShadowRoot: false,
});
}
// Expose functions globally for testing and dynamic mounting
declare global {
interface Window {
UnraidComponents: Record<string, Component>;
mountVueApp: typeof mountVueApp;
getMountedApp: typeof getMountedApp;
}
}
if (typeof window !== 'undefined') {
// Expose all components
window.UnraidComponents = {
Auth,
ConnectSettings,
DownloadApiLogs,
HeaderOsVersion,
Modals,
UserProfile,
UpdateOs,
DowngradeOs,
Registration,
WanIpCheck,
WelcomeModal,
SsoButton,
LogViewer,
ThemeSwitcher,
ApiKeyPage,
DevModalTest,
ApiKeyAuthorize,
};
// Expose utility functions
window.mountVueApp = mountVueApp;
window.getMountedApp = getMountedApp;
// Create dynamic mount functions for each component
componentMappings.forEach(({ component, selector, appId }) => {
const componentName = appId.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join('');
(window as unknown as Record<string, unknown>)[`mount${componentName}`] = (customSelector?: string) => {
return mountVueApp({
component,
selector: customSelector || selector,
appId: `${appId}-${Date.now()}`,
useShadowRoot: false,
});
};
});
}

View File

@@ -4,11 +4,29 @@ import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
import { getMainDefinition } from '@apollo/client/utilities/index.js';
import { createClient } from 'graphql-ws';
import type { ErrorResponse } from '@apollo/client/link/error/index.js';
import type { GraphQLFormattedError } from 'graphql';
import { createApolloCache } from './apollo-cache';
import { WEBGUI_GRAPHQL } from './urls';
const httpEndpoint = WEBGUI_GRAPHQL;
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws'));
// Allow overriding the GraphQL endpoint for development/testing
declare global {
interface Window {
GRAPHQL_ENDPOINT?: string;
}
}
const getGraphQLEndpoint = () => {
if (typeof window !== 'undefined' && window.GRAPHQL_ENDPOINT) {
return new URL(window.GRAPHQL_ENDPOINT);
}
return WEBGUI_GRAPHQL;
};
const httpEndpoint = getGraphQLEndpoint();
const wsEndpoint = new URL(httpEndpoint.toString().replace('http', 'ws'));
const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false;
const headers = {
@@ -28,17 +46,15 @@ const wsLink = new GraphQLWsLink(
})
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorLink = onError(({ graphQLErrors, networkError }: any) => {
const errorLink = onError(({ graphQLErrors, networkError }: ErrorResponse) => {
if (graphQLErrors) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
graphQLErrors.map((error: any) => {
graphQLErrors.forEach((error: GraphQLFormattedError) => {
console.error('[GraphQL error]', error);
const errorMsg = error.error?.message ?? error.message;
const errorMsg =
(error as GraphQLFormattedError & { error?: { message?: string } }).error?.message ?? error.message;
if (errorMsg?.includes('offline')) {
// @todo restart the api, but make sure not to trigger infinite loop
}
return error.message;
});
}
@@ -46,9 +62,8 @@ const errorLink = onError(({ graphQLErrors, networkError }: any) => {
console.error(`[Network error]: ${networkError}`);
const msg = networkError.message ? networkError.message : networkError;
if (typeof msg === 'string' && msg.includes('Unexpected token < in JSON at position 0')) {
return 'Unraid API • CORS Error';
console.error('Unraid API • CORS Error');
}
return msg;
}
});
@@ -69,7 +84,7 @@ const retryLink = new RetryLink({
// Disable Apollo Client if not in DEV Mode and server state says unraid-api is not running
const disableQueryLink = new ApolloLink((operation, forward) => {
if (!DEV_MODE && operation.getContext().serverState?.unraidApi?.status === 'offline') {
return null;
return null;
}
return forward(operation);
});

View File

@@ -3,7 +3,7 @@ import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import removeConsole from 'vite-plugin-remove-console';
import type { PluginOption, UserConfig } from 'vite';
import type { PluginOption } from 'vite';
/**
* Used to avoid redeclaring variables in the webgui codebase.
@@ -34,15 +34,13 @@ console.log(dropConsole ? 'WARN: Console logs are disabled' : 'INFO: Console log
const assetsDir = path.join(__dirname, '../api/dev/webGui/');
/**
* Create a tag configuration
*/
const createWebComponentTag = (name: string, path: string, appContext: string) => ({
async: false,
name,
path,
appContext
});
// REMOVED: No longer needed with standalone mount approach
// const createWebComponentTag = (name: string, path: string, appContext: string) => ({
// async: false,
// name,
// path,
// appContext
// });
/**
* Shared terser options for consistent minification
@@ -118,26 +116,24 @@ const sharedDefine = {
__VUE_PROD_DEVTOOLS__: false,
};
/**
* Apply shared Vite configuration to a config object
*/
const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
if (!config.plugins) config.plugins = [];
if (!config.define) config.define = {};
if (!config.build) config.build = {};
// Add shared plugins
config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
// Merge define values
Object.assign(config.define, sharedDefine);
// Apply build configuration
config.build.minify = 'terser';
config.build.terserOptions = sharedTerserOptions;
return config;
};
// REMOVED: No longer needed with standalone mount approach
// const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
// if (!config.plugins) config.plugins = [];
// if (!config.define) config.define = {};
// if (!config.build) config.build = {};
//
// // Add shared plugins
// config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
//
// // Merge define values
// Object.assign(config.define, sharedDefine);
//
// // Apply build configuration
// config.build.minify = 'terser';
// config.build.terserOptions = sharedTerserOptions;
//
// return config;
// };
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
@@ -205,45 +201,11 @@ export default defineNuxtConfig({
},
},
customElements: {
analyzer: process.env.NODE_ENV !== 'test',
entries: [
// @ts-expect-error The nuxt-custom-elements module types don't perfectly match our configuration object structure.
// The custom elements configuration requires specific properties and methods that may not align with the
// module's TypeScript definitions, particularly around the viteExtend function and tag configuration format.
{
name: 'UnraidComponents',
viteExtend(config: UserConfig) {
const sharedConfig = applySharedViteConfig(config, true);
// Optimize CSS while keeping it inlined for functionality
if (!sharedConfig.css) sharedConfig.css = {};
sharedConfig.css.devSourcemap = process.env.NODE_ENV === 'development';
return sharedConfig;
},
tags: [
createWebComponentTag('UnraidAuth', '@/components/Auth.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidConnectSettings', '@/components/ConnectSettings/ConnectSettings.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDownloadApiLogs', '@/components/DownloadApiLogs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidHeaderOsVersion', '@/components/HeaderOsVersion.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidModals', '@/components/Modals.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidUserProfile', '@/components/UserProfile.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidUpdateOs', '@/components/UpdateOs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDowngradeOs', '@/components/DowngradeOs.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidRegistration', '@/components/Registration.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidWanIpCheck', '@/components/WanIpCheck.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidWelcomeModal', '@/components/Activation/WelcomeModal.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidSsoButton', '@/components/SsoButton.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidLogViewer', '@/components/Logs/LogViewer.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidThemeSwitcher', '@/components/ThemeSwitcher.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidApiKeyManager', '@/components/ApiKeyPage.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidDevModalTest', '@/components/DevModalTest.ce', '@/components/Wrapper/web-component-plugins'),
createWebComponentTag('UnraidApiKeyAuthorize', '@/components/ApiKeyAuthorize.ce', '@/components/Wrapper/web-component-plugins'),
],
},
],
},
// DISABLED: Using standalone mount approach instead
// customElements: {
// analyzer: process.env.NODE_ENV !== 'test',
// entries: [],
// },
compatibilityDate: '2024-12-05',

View File

@@ -13,11 +13,13 @@
"prebuild:dev": "pnpm predev",
"build:dev": "nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run deploy-to-unraid:dev",
"build:webgui": "pnpm run type-check && nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run copy-to-webgui-repo",
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run validate:css",
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run build:standalone && pnpm run manifest-ts && pnpm run validate:css",
"build:standalone": "vite build --config vite.standalone.config.ts && pnpm run manifest-standalone",
"prebuild:watch": "pnpm predev",
"build:watch": "nuxi build --dotenv .env.production --watch && pnpm run manifest-ts",
"generate": "nuxt generate",
"manifest-ts": "node ./scripts/add-timestamp-webcomponent-manifest.js",
"manifest-standalone": "node ./scripts/add-timestamp-standalone-manifest.js",
"validate:css": "node ./scripts/validate-custom-elements-css.js",
"// Deployment": "",
"unraid:deploy": "pnpm build:dev",
@@ -35,6 +37,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run",
"test:standalone": "pnpm run build:standalone && vite --config vite.test.config.ts",
"// Nuxt": "",
"postinstall": "nuxt prepare"
},

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 222.36 39.04"><defs><linearGradient id="header-logo" x1="47.53" y1="79.1" x2="170.71" y2="-44.08" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e32929"/><stop offset="1" stop-color="#ff8d30"/></linearGradient></defs><title>unraid.net</title><path d="M146.7,29.47H135l-3,9h-6.49L138.93,0h8l13.41,38.49h-7.09L142.62,6.93l-5.83,16.88h8ZM29.69,0V25.4c0,8.91-5.77,13.64-14.9,13.64S0,34.31,0,25.4V0H6.54V25.4c0,5.17,3.19,7.92,8.25,7.92s8.36-2.75,8.36-7.92V0ZM50.86,12v26.5H44.31V0h6.11l17,26.5V0H74V38.49H67.9ZM171.29,0h6.54V38.49h-6.54Zm51.07,24.69c0,9-5.88,13.8-15.17,13.8H192.67V0H207.3c9.18,0,15.06,4.78,15.06,13.8ZM215.82,13.8c0-5.28-3.3-8.14-8.52-8.14h-8.08V32.77h8c5.33,0,8.63-2.8,8.63-8.08ZM108.31,23.92c4.34-1.6,6.93-5.28,6.93-11.55C115.24,3.68,110.18,0,102.48,0H88.84V38.49h6.55V5.66h6.87c3.8,0,6.21,1.82,6.21,6.71s-2.41,6.76-6.21,6.76H98.88l9.21,19.36h7.53Z" fill="url(#header-logo)"/></svg>

After

Width:  |  Height:  |  Size: 1008 B

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const distPath = '.nuxt/standalone-apps';
const manifestPath = path.join(distPath, 'standalone.manifest.json');
// Get all JS files in the dist directory
const files = fs.readdirSync(distPath);
const manifest = {};
files.forEach(file => {
if (file.endsWith('.js') || file.endsWith('.css')) {
const key = file.replace(/\.(js|css)$/, '.$1');
manifest[key] = {
file: file,
src: file,
};
}
});
// Add timestamp
manifest.ts = Date.now();
// Write manifest
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('Standalone apps manifest created:', manifestPath);

View File

@@ -2,6 +2,13 @@ const fs = require('fs');
// Read the JSON file
const filePath = '../web/.nuxt/nuxt-custom-elements/dist/unraid-components/manifest.json';
// Check if file exists (web components are now disabled in favor of standalone)
if (!fs.existsSync(filePath)) {
console.log('Web components manifest not found (using standalone mount instead)');
process.exit(0);
}
const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Add timestamp (ts) to the JSON data

View File

@@ -10,8 +10,9 @@ fi
# Set server name from command-line argument
server_name="$1"
# Source directory path
# Source directory paths
source_directory=".nuxt/nuxt-custom-elements/dist/unraid-components/"
standalone_directory=".nuxt/standalone-apps/"
if [ ! -d "$source_directory" ]; then
echo "The web components directory does not exist."
@@ -24,6 +25,11 @@ ssh "root@${server_name}" "rm -rf /usr/local/emhttp/plugins/dynamix.my.servers/u
rsync_command="rsync -avz -e ssh $source_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt"
# Also sync standalone apps if they exist
if [ -d "$standalone_directory" ]; then
rsync_standalone="rsync -avz -e ssh $standalone_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone"
fi
echo "Executing the following command:"
echo "$rsync_command"
@@ -31,6 +37,13 @@ echo "$rsync_command"
eval "$rsync_command"
exit_code=$?
# Execute standalone rsync if directory exists
if [ -n "$rsync_standalone" ]; then
echo "Executing standalone apps sync:"
echo "$rsync_standalone"
eval "$rsync_standalone"
fi
# Update the auth-request.php file to include the new web component JS
update_auth_request() {
local server_name="$1"
@@ -38,9 +51,16 @@ update_auth_request() {
ssh "root@${server_name}" "
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
WEB_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/_nuxt/'
STANDALONE_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/'
# Find JS files and modify paths
mapfile -t JS_FILES < <(find \"\$WEB_COMPS_DIR\" -type f -name \"*.js\" | sed 's|/usr/local/emhttp||' | sort -u)
# Find standalone JS files if directory exists
if [ -d \"\$STANDALONE_DIR\" ]; then
mapfile -t STANDALONE_JS < <(find \"\$STANDALONE_DIR\" -type f -name \"*.js\" | sed 's|/usr/local/emhttp||' | sort -u)
FILES_TO_ADD+=(\"\${STANDALONE_JS[@]}\")
fi
FILES_TO_ADD+=(\"\${JS_FILES[@]}\")

View File

@@ -29,16 +29,26 @@ function findJSFiles(dir, jsFiles = []) {
* Validates that Tailwind CSS styles are properly inlined in the JavaScript bundle
*/
function validateCustomElementsCSS() {
console.log('🔍 Validating custom elements JS bundle includes inlined Tailwind styles...');
console.log('🔍 Validating JS bundle includes inlined Tailwind styles...');
try {
// Find the custom elements JS files
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
const jsFiles = findJSFiles(customElementsDir);
// Check standalone apps first (new approach)
const standaloneDir = '.nuxt/standalone-apps';
let jsFiles = findJSFiles(standaloneDir);
let usingStandalone = true;
// Fallback to custom elements if standalone doesn't exist
if (jsFiles.length === 0) {
throw new Error('No custom elements JS files found in ' + customElementsDir);
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
jsFiles = findJSFiles(customElementsDir);
usingStandalone = false;
if (jsFiles.length === 0) {
throw new Error('No JS files found in standalone apps or custom elements dist');
}
}
console.log(`📦 Using ${usingStandalone ? 'standalone apps' : 'custom elements'} bundle`);
// Find the largest JS file (likely the main bundle with inlined CSS)
const jsFile = jsFiles.reduce((largest, current) => {

330
web/test-standalone.html Normal file
View File

@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Standalone Vue Apps Test Page</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.test-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
}
h2 {
color: #666;
margin-top: 0;
margin-bottom: 15px;
font-size: 18px;
}
.status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
font-family: monospace;
font-size: 14px;
}
.status.loading {
background: #fff3cd;
color: #856404;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.mount-target {
padding: 20px;
background: #fafafa;
border: 2px dashed #ddd;
border-radius: 4px;
min-height: 100px;
position: relative;
}
.mount-target::before {
content: attr(data-label);
position: absolute;
top: -10px;
left: 10px;
background: white;
padding: 0 5px;
color: #999;
font-size: 12px;
}
.debug-info {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
}
.multiple-mounts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.test-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.test-button:hover {
background: #0056b3;
}
.test-button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
</head>
<body>
<!-- Teleport target for dropdowns and modals -->
<div id="teleports"></div>
<!-- Mount point for Modals component -->
<div id="modals"></div>
<!-- Alternative: Use custom element -->
<unraid-modals></unraid-modals>
<div class="container">
<h1>🧪 Standalone Vue Apps Test Page</h1>
<div id="status" class="status loading">Loading...</div>
<!-- Test Section 1: Single Mount -->
<div class="test-section">
<h2>Test 1: Single Component Mount</h2>
<p>Testing single instance of HeaderOsVersion component</p>
<div class="mount-target" data-label="HeaderOsVersion Mount">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
<!-- Test Section 2: Multiple Mounts -->
<div class="test-section">
<h2>Test 2: Multiple Component Mounts (Shared Pinia Store)</h2>
<p>Testing that multiple instances share the same Pinia store</p>
<div class="multiple-mounts">
<div class="mount-target" data-label="Instance 1">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 2">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 3">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
</div>
<!-- Test Section 3: Dynamic Mount -->
<div class="test-section">
<h2>Test 3: Dynamic Component Creation</h2>
<p>Test dynamically adding components after page load</p>
<button class="test-button" id="addComponent">Add New Component</button>
<button class="test-button" id="removeComponent">Remove Last Component</button>
<button class="test-button" id="remountAll">Remount All</button>
<div id="dynamicContainer" style="margin-top: 20px;">
<!-- Dynamic components will be added here -->
</div>
</div>
<!-- Test Section 4: Modal Testing -->
<div class="test-section">
<h2>Test 4: Modal Components</h2>
<p>Test modal functionality</p>
<button class="test-button" onclick="testTrialModal()">Open Trial Modal</button>
<button class="test-button" onclick="testUpdateModal()">Open Update Modal</button>
<button class="test-button" onclick="testApiKeyModal()">Open API Key Modal</button>
<div style="margin-top: 10px;">
<small>Note: Modals require proper store state to display</small>
</div>
</div>
<!-- Debug Info -->
<div class="test-section">
<h2>Debug Information</h2>
<div class="debug-info" id="debugInfo">
Waiting for initialization...
</div>
</div>
</div>
<!-- Mock configurations for local testing -->
<script>
// Set GraphQL endpoint directly to API server
// Change this to match your API server port
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
// Mock webGui path for images
window.__WEBGUI_PATH__ = '';
// Add some debug logging
window.addEventListener('DOMContentLoaded', () => {
const status = document.getElementById('status');
const debugInfo = document.getElementById('debugInfo');
// Log when scripts are loaded
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'SCRIPT') {
console.log('Script loaded:', node.src || 'inline');
}
});
}
});
});
observer.observe(document.head, { childList: true });
observer.observe(document.body, { childList: true });
// Check for Vue app mounting
let checkInterval = setInterval(() => {
const mountedElements = document.querySelectorAll('unraid-header-os-version');
let mountedCount = 0;
mountedElements.forEach(el => {
if (el.innerHTML.trim() !== '') {
mountedCount++;
}
});
if (mountedCount > 0) {
status.className = 'status success';
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
// Update debug info
debugInfo.textContent = `
Components Found: ${mountedElements.length}
Components Mounted: ${mountedCount}
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
`.trim();
clearInterval(checkInterval);
}
}, 500);
// Timeout after 10 seconds
setTimeout(() => {
if (checkInterval) {
clearInterval(checkInterval);
if (status.className === 'status loading') {
status.className = 'status error';
status.textContent = '❌ Failed to mount components (timeout)';
}
}
}, 10000);
});
// Dynamic component controls
document.addEventListener('DOMContentLoaded', () => {
let dynamicCount = 0;
const dynamicContainer = document.getElementById('dynamicContainer');
document.getElementById('addComponent').addEventListener('click', () => {
dynamicCount++;
const wrapper = document.createElement('div');
wrapper.className = 'mount-target';
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
wrapper.style.marginBottom = '10px';
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
dynamicContainer.appendChild(wrapper);
// Trigger mount if app is already loaded
if (window.mountVueApp) {
window.mountVueApp({
component: window.HeaderOsVersion,
selector: 'unraid-header-os-version',
appId: `dynamic-${dynamicCount}`,
});
}
});
document.getElementById('removeComponent').addEventListener('click', () => {
const lastChild = dynamicContainer.lastElementChild;
if (lastChild) {
dynamicContainer.removeChild(lastChild);
dynamicCount = Math.max(0, dynamicCount - 1);
}
});
document.getElementById('remountAll').addEventListener('click', () => {
// This would require the mount function to be exposed globally
console.log('Remounting all components...');
location.reload();
});
});
// Modal test functions
window.testTrialModal = function() {
console.log('Testing trial modal...');
if (window.globalPinia) {
const trialStore = window.globalPinia._s.get('trial');
if (trialStore) {
trialStore.trialModalVisible = true;
console.log('Trial modal triggered');
} else {
console.error('Trial store not found');
}
}
};
window.testUpdateModal = function() {
console.log('Testing update modal...');
if (window.globalPinia) {
const updateStore = window.globalPinia._s.get('updateOs');
if (updateStore) {
updateStore.updateOsModalVisible = true;
console.log('Update modal triggered');
} else {
console.error('Update store not found');
}
}
};
window.testApiKeyModal = function() {
console.log('Testing API key modal...');
if (window.globalPinia) {
const apiKeyStore = window.globalPinia._s.get('apiKey');
if (apiKeyStore) {
apiKeyStore.showCreateModal = true;
console.log('API key modal triggered');
} else {
console.error('API key store not found');
}
}
};
</script>
<!-- Load the standalone app -->
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path, { resolve } from 'path';
import fs from 'fs';
// Read CSS content at build time
const getCssContent = () => {
const cssFiles = [
'.nuxt/dist/client/_nuxt/entry.DXd6OtrS.css',
'.output/public/_nuxt/entry.DXd6OtrS.css',
'assets/main.css'
];
for (const file of cssFiles) {
const fullPath = path.resolve(__dirname, file);
if (fs.existsSync(fullPath)) {
console.log(`Reading CSS from: ${fullPath}`);
return fs.readFileSync(fullPath, 'utf-8');
}
}
console.warn('No CSS file found, using empty string');
return '';
};
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-content',
transform(code, id) {
// Replace CSS import with actual content
if (id.includes('vue-mount-app')) {
const cssContent = getCssContent();
const replacement = `const tailwindStyles = ${JSON.stringify(cssContent)};`;
// Replace the import statement
code = code.replace(
/import tailwindStyles from ['"]~\/assets\/main\.css\?inline['"];?/,
replacement
);
return code;
}
return null;
},
},
],
resolve: {
alias: {
'~': resolve(__dirname, './'),
'@': resolve(__dirname, './'),
},
},
build: {
outDir: '.nuxt/standalone-apps',
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'components/standalone-mount.ts'),
name: 'UnraidStandaloneApps',
fileName: 'standalone-apps',
formats: ['es'],
},
rollupOptions: {
external: [],
output: {
format: 'es',
entryFileNames: 'standalone-apps.js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name]-[hash][extname]',
inlineDynamicImports: false,
},
},
cssCodeSplit: false,
minify: 'terser',
terserOptions: {
mangle: {
toplevel: true,
},
},
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
},
});

26
web/vite.test.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
root: '.',
server: {
port: 5173,
open: '/test-standalone.html',
cors: true,
fs: {
strict: false,
},
},
resolve: {
alias: {
'~': resolve(__dirname, './'),
'@': resolve(__dirname, './'),
'vue': 'vue/dist/vue.esm-bundler.js',
},
},
optimizeDeps: {
include: ['vue', 'pinia', '@vue/apollo-composable'],
},
});