diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index b996b8ffc..c8663a580 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -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! diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php index ae8e439e0..a40491ae7 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php @@ -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 '; } + 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 ' + '; + } + 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 diff --git a/web/components/Wrapper/vue-mount-app.ts b/web/components/Wrapper/vue-mount-app.ts new file mode 100644 index 000000000..5e1101fa8 --- /dev/null +++ b/web/components/Wrapper/vue-mount-app.ts @@ -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(); + +// Shared style injection tracking +const styleInjected = new WeakSet(); + +// Expose globally for debugging +declare global { + interface Window { + mountedApps: Map; + 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; +} + +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) { + // 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 }); + } +} diff --git a/web/components/standalone-mount.ts b/web/components/standalone-mount.ts new file mode 100644 index 000000000..d001775d7 --- /dev/null +++ b/web/components/standalone-mount.ts @@ -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; + 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)[`mount${componentName}`] = (customSelector?: string) => { + return mountVueApp({ + component, + selector: customSelector || selector, + appId: `${appId}-${Date.now()}`, + useShadowRoot: false, + }); + }; + }); +} diff --git a/web/helpers/create-apollo-client.ts b/web/helpers/create-apollo-client.ts index ef5907bff..240f0f9a2 100644 --- a/web/helpers/create-apollo-client.ts +++ b/web/helpers/create-apollo-client.ts @@ -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); }); diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index 42835796f..bf5a4e657 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -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', diff --git a/web/package.json b/web/package.json index f150185b8..d80db4c36 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/public/images/UN-logotype-gradient.svg b/web/public/images/UN-logotype-gradient.svg new file mode 100644 index 000000000..fe0873037 --- /dev/null +++ b/web/public/images/UN-logotype-gradient.svg @@ -0,0 +1 @@ +unraid.net diff --git a/web/scripts/add-timestamp-standalone-manifest.js b/web/scripts/add-timestamp-standalone-manifest.js new file mode 100644 index 000000000..9e57f75a7 --- /dev/null +++ b/web/scripts/add-timestamp-standalone-manifest.js @@ -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); diff --git a/web/scripts/add-timestamp-webcomponent-manifest.js b/web/scripts/add-timestamp-webcomponent-manifest.js index 3b32b6efe..a797c176a 100644 --- a/web/scripts/add-timestamp-webcomponent-manifest.js +++ b/web/scripts/add-timestamp-webcomponent-manifest.js @@ -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 diff --git a/web/scripts/deploy-dev.sh b/web/scripts/deploy-dev.sh index 0c1fa7230..f62a6e990 100755 --- a/web/scripts/deploy-dev.sh +++ b/web/scripts/deploy-dev.sh @@ -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[@]}\") diff --git a/web/scripts/validate-custom-elements-css.js b/web/scripts/validate-custom-elements-css.js index d9a042a4c..3d4f5f39d 100644 --- a/web/scripts/validate-custom-elements-css.js +++ b/web/scripts/validate-custom-elements-css.js @@ -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) => { diff --git a/web/test-standalone.html b/web/test-standalone.html new file mode 100644 index 000000000..12f326263 --- /dev/null +++ b/web/test-standalone.html @@ -0,0 +1,330 @@ + + + + + + Standalone Vue Apps Test Page + + + + +
+ + +
+ + + + +
+

๐Ÿงช Standalone Vue Apps Test Page

+
Loading...
+ + +
+

Test 1: Single Component Mount

+

Testing single instance of HeaderOsVersion component

+
+ +
+
+ + +
+

Test 2: Multiple Component Mounts (Shared Pinia Store)

+

Testing that multiple instances share the same Pinia store

+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+

Test 3: Dynamic Component Creation

+

Test dynamically adding components after page load

+ + + +
+ +
+
+ + +
+

Test 4: Modal Components

+

Test modal functionality

+ + + +
+ Note: Modals require proper store state to display +
+
+ + +
+

Debug Information

+
+ Waiting for initialization... +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/web/vite.standalone.config.ts b/web/vite.standalone.config.ts new file mode 100644 index 000000000..a9496c004 --- /dev/null +++ b/web/vite.standalone.config.ts @@ -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'), + }, +}); diff --git a/web/vite.test.config.ts b/web/vite.test.config.ts new file mode 100644 index 000000000..c49709e97 --- /dev/null +++ b/web/vite.test.config.ts @@ -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'], + }, +});