Files
api/web/src/components/Wrapper/mount-engine.ts
Eli Bosley 36c104915e fix: missing translations for expiring trials (#1800)
- Removed translation function calls from the UI components for reboot
type text, replacing them with direct references to the computed
properties.
- Enhanced ineligible update messages by integrating localization for
various conditions, ensuring clearer user feedback regarding update
eligibility.
- Added new localization strings for ineligible update scenarios in the
English locale file.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added new localization keys for OS update eligibility, reboot labels,
changelog link, and expanded uptime/trial expiry messages.

* **Bug Fixes**
* Restored translated strings and added locale-aware release date
formatting for update/ineligible messaging and badges.

* **Theme & UI**
* Streamlined theme initialization and server-driven theme application;
removed legacy CSS-variable persistence and adjusted dark/banner
behavior.

* **Tests**
* Added i18n and date/locale formatting tests and improved
local-storage-like test mocks.

* **Chores**
* Removed an auto-registered global component and strengthened
script/theme initialization and CSS-variable validation.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-20 19:30:39 -05:00

198 lines
6.0 KiB
TypeScript

import { createApp, createVNode, h, render } from 'vue';
import { DefaultApolloClient } from '@vue/apollo-composable';
import UApp from '@nuxt/ui/components/App.vue';
import ui from '@nuxt/ui/vue-plugin';
// Import component registry (only imported here to avoid ordering issues)
import { componentMappings } from '@/components/Wrapper/component-registry';
import { client } from '~/helpers/create-apollo-client';
import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18n-loader';
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
// Expose globally for debugging
declare global {
interface Window {
globalPinia: typeof globalPinia;
LOCALE?: string;
}
}
if (typeof window !== 'undefined') {
window.globalPinia = globalPinia;
}
async function setupI18n() {
const i18n = createI18nInstance();
await ensureLocale(i18n, getWindowLocale());
return i18n;
}
// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
// Early exit if no attributes
if (!element.hasAttributes()) return {};
const props: Record<string, unknown> = {};
// Pre-compile attribute skip list into a Set for O(1) lookup
const skipAttrs = new Set(['class', 'id', 'style', 'data-vue-mounted']);
for (const attr of element.attributes) {
const name = attr.name;
// Skip Vue internal attributes and common HTML attributes
if (skipAttrs.has(name) || name.startsWith('data-v-')) {
continue;
}
const value = attr.value;
const first = value.trimStart()[0];
// Try to parse JSON values (handles HTML-encoded JSON)
if (first === '{' || first === '[') {
try {
// Decode HTML entities first
const decoded = value
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'");
props[name] = JSON.parse(decoded);
} catch (_e) {
// If JSON parsing fails, use as string
props[name] = value;
}
} else {
props[name] = value;
}
}
return props;
}
// Create and mount unified app with shared context
export async function mountUnifiedApp() {
// Create a minimal app just for context sharing
const app = createApp({
name: 'UnifiedContextApp',
render: () => h('div', 'Context Provider'),
});
// Setup everything once
const i18n = await setupI18n();
app.use(i18n);
app.use(globalPinia);
app.use(ui);
app.provide(DefaultApolloClient, apolloClient);
// Mount the app to establish context
let rootElement = document.getElementById('unraid-unified-root');
if (!rootElement) {
rootElement = document.createElement('div');
rootElement.id = 'unraid-unified-root';
rootElement.style.display = 'none';
document.body.appendChild(rootElement);
}
app.mount(rootElement);
// Now render components to their locations using the shared context
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
// Batch all selector queries first to identify which components are needed
const componentsToMount: Array<{ mapping: (typeof componentMappings)[0]; element: HTMLElement }> = [];
// Build a map of all selectors to their mappings for quick lookup
const selectorToMapping = new Map<string, (typeof componentMappings)[0]>();
componentMappings.forEach((mapping) => {
const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
});
// Query all selectors at once
const allSelectors = Array.from(selectorToMapping.keys()).join(',');
// Early exit if no selectors to query
if (!allSelectors) {
console.debug('[UnifiedMount] Mounted 0 components');
return app;
}
const foundElements = document.querySelectorAll(allSelectors);
const processedMappings = new Set<(typeof componentMappings)[0]>();
foundElements.forEach((element) => {
if (!element.hasAttribute('data-vue-mounted')) {
// Find which mapping this element belongs to
for (const [selector, mapping] of selectorToMapping) {
if (element.matches(selector) && !processedMappings.has(mapping)) {
componentsToMount.push({ mapping, element: element as HTMLElement });
processedMappings.add(mapping);
break;
}
}
}
});
// Now mount only the components that exist
componentsToMount.forEach(({ mapping, element }) => {
const { appId } = mapping;
const component = mapping.component;
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
return;
}
// Parse props from element
const props = parsePropsFromElement(element);
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.replaceChildren();
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
});
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
return app;
}
// Replace the old autoMountAllComponents with the new unified approach
export async function autoMountAllComponents() {
return await mountUnifiedApp();
}