diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cc2f0386..0f0583268 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: src/ui: dependencies: + '@originjs/vite-plugin-federation': + specifier: ^1.3.6 + version: 1.3.6 '@radix-ui/react-dialog': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1)(react@18.3.1) @@ -1251,6 +1254,14 @@ packages: '@octokit/openapi-types': 12.11.0 dev: true + /@originjs/vite-plugin-federation@1.3.6: + resolution: {integrity: sha512-tHLMjdMJFPFMSJrUuJJiv8l7OFRvM19E9O1B9dhbk+04i3RnYwE9A6oNtSUM1dnvkalzCLwZIuMpti28/tnh8g==} + engines: {node: '>=14.0.0', pnpm: '>=7.0.1'} + dependencies: + estree-walker: 3.0.3 + magic-string: 0.27.0 + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -3337,6 +3348,12 @@ packages: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: true + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + dev: false + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4566,6 +4583,13 @@ packages: sourcemap-codec: 1.4.8 dev: true + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: false + /make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true diff --git a/src/client/services/PhoneService.ts b/src/client/services/PhoneService.ts index 3b7ade51b..50999ae0d 100644 --- a/src/client/services/PhoneService.ts +++ b/src/client/services/PhoneService.ts @@ -43,7 +43,7 @@ class PhoneService { this.state = 'open'; global.SendNUIMessage({ type: 'SET_PHONE_OPEN', payload: true }); SetNuiFocus(true, true); - SetNuiFocusKeepInput(true); + // SetNuiFocusKeepInput(true); await this.animate.openPhone(); } diff --git a/src/server/router/contacts.ts b/src/server/router/contacts.ts index e494f67da..258182001 100644 --- a/src/server/router/contacts.ts +++ b/src/server/router/contacts.ts @@ -16,22 +16,22 @@ const contactIdSchema = z.object({ contactId: z.coerce.number(), }); -contactsRouter.add('/:contactId', async (ctx, next) => { - try { - const { contactId } = contactIdSchema.parse(ctx.params); - const contact = await ContactService.getContactById(ctx, contactId); - - ctx.status = 200; - ctx.body = { - ok: true, - payload: contact, - }; - } catch (error) { - handleError(error, ctx); - } - - await next(); -}); +// contactsRouter.add('/:contactId', async (ctx, next) => { +// try { +// const { contactId } = contactIdSchema.parse(ctx.params); +// const contact = await ContactService.getContactById(ctx, contactId); + +// ctx.status = 200; +// ctx.body = { +// ok: true, +// payload: contact, +// }; +// } catch (error) { +// handleError(error, ctx); +// } + +// await next(); +// }); contactsRouter.add('/add', async (ctx, next) => { try { diff --git a/src/server/services/MessageService.ts b/src/server/services/MessageService.ts index 745659435..7c969d797 100644 --- a/src/server/services/MessageService.ts +++ b/src/server/services/MessageService.ts @@ -10,6 +10,9 @@ import { } from '../../shared/Errors'; import DeviceRepository from '../repositories/DeviceRepository'; import { Message } from '../../shared/Types'; +import { getPlayerSrcBySid } from '../utils/player'; +import { parseObjectToIsoString } from '../utils/date'; +import { handleError } from '../utils/errors'; class MessageService { private readonly messageRepository: typeof MessageRepository; @@ -27,15 +30,15 @@ class MessageService { } public async sendMessage( - { device }: RouterContext, + ctx: RouterContext, content: string, receiverPhoneNumber: string, ): Promise { - if (!device.sim_card_id) { + if (!ctx.device.sim_card_id) { throw new SimcardNotFoundError('SENDER'); } - if (!device.sim_card_is_active) { + if (!ctx.device.sim_card_is_active) { throw new SimCardNotActiveError('SENDER'); } @@ -52,14 +55,29 @@ class MessageService { } const message = await this.messageRepository.createMessage({ - sender_id: device.sim_card_id, + sender_id: ctx.device.sim_card_id, receiver_id: receiverSimcard.id, content, }); + this.broadcastNewMessage(ctx, message); + return message; } + public async broadcastNewMessage(ctx: RouterContext, message: Message): Promise { + try { + const callerSrc = await getPlayerSrcBySid(message.sender_id); + const receiverSrc = await getPlayerSrcBySid(message.receiver_id); + + ctx.emitToNui(callerSrc, 'message:new-message', parseObjectToIsoString(message)); + ctx.emitToNui(receiverSrc, 'message:new-message', parseObjectToIsoString(message)); + } catch (error) { + console.error('Failed to broadcast call update', error); + handleError(error, ctx); + } + } + public async getMessages(ctx: RouterContext): Promise { const device = await this.deviceRepository.getDeviceById(ctx.device.id); @@ -72,7 +90,6 @@ class MessageService { } const messages = await this.messageRepository.getMessagesBySid(device.sim_card_id); - console.log({ messages }); return messages; } diff --git a/src/shared/Types.ts b/src/shared/Types.ts index 0a1442122..1ada8c6cf 100644 --- a/src/shared/Types.ts +++ b/src/shared/Types.ts @@ -36,7 +36,7 @@ export interface Call extends Record { acknowledged_at?: Date; } -export interface Message { +export interface Message extends Record { id: number; sender_id: number; receiver_id: number; diff --git a/src/ui/package.json b/src/ui/package.json index 938c1954d..38f9136d2 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@originjs/vite-plugin-federation": "^1.3.6", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.55.1", diff --git a/src/ui/src/App.tsx b/src/ui/src/App.tsx index 4be3a3832..614eafdea 100644 --- a/src/ui/src/App.tsx +++ b/src/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Frame } from './Frame'; import { useEffect } from 'react'; @@ -14,6 +14,7 @@ import { useCurrentDevice } from './api/hooks/useCurrentDevice'; import { motion, useMotionValue, useTransform } from 'framer-motion'; import { useKeys } from './hooks/useKeys'; import { useThemeType } from './hooks/useTheme'; +import { Message } from '../../shared/Types'; export const lightTheme: Theme = { type: 'light', @@ -40,6 +41,7 @@ export const darkTheme: Theme = { }; function App() { + const location = useLocation(); const navigate = useNavigate(); const currentDevice = useCurrentDevice(); const y = useMotionValue(0); @@ -82,6 +84,12 @@ function App() { }, }); + useEffect(() => { + if (location.pathname === '/') { + navigate('/home'); + } + }, [location.pathname]); + /** * If there is no device, we should not render anything. * This is a safety check to ensure that the app does not render diff --git a/src/ui/src/Apps/Calls/Contacts/New/index.tsx b/src/ui/src/Apps/Calls/Contacts/New/index.tsx index 93dc166b7..27315402b 100644 --- a/src/ui/src/Apps/Calls/Contacts/New/index.tsx +++ b/src/ui/src/Apps/Calls/Contacts/New/index.tsx @@ -19,8 +19,7 @@ export const NewContactView = () => { - Are you absolutely sure? - This action cannot be undone. + Create a new contact diff --git a/src/ui/src/Apps/Calls/Messages/Conversation/index.tsx b/src/ui/src/Apps/Calls/Messages/Conversation/index.tsx index 2bb332bcd..a29b2bf83 100644 --- a/src/ui/src/Apps/Calls/Messages/Conversation/index.tsx +++ b/src/ui/src/Apps/Calls/Messages/Conversation/index.tsx @@ -1,16 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; import { TopNavigation } from '../../../../components/Navigation/TopNavigation'; import { useNavigate, useParams } from 'react-router'; -import { Message } from '../../../../../../shared/Types'; import { instance } from '../../../../utils/fetch'; import { useCurrentDevice } from '../../../../api/hooks/useCurrentDevice'; import { clsx } from 'clsx'; import { queryClient } from '../../../../Providers'; import { Fragment, useEffect, useRef } from 'react'; import { useConversationMessages } from '../../../../api/hooks/useConversation'; -import { Send, UserCheck } from 'react-feather'; -import { useThemeType } from '../../../../hooks/useTheme'; +import { Send } from 'react-feather'; import { DateTime } from 'luxon'; +import { AlignVerticalSpaceAround } from 'lucide-react'; export const Conversation = () => { const navigate = useNavigate(); @@ -78,13 +76,10 @@ export const Conversation = () => { messagesList.scrollTo(0, messagesList.scrollHeight); }, [messages.length]); - const themeType = useThemeType(); - const bgColor = themeType === 'dark' ? 'bg-cyan-800' : 'bg-cyan-100'; - return (
} left={ + ); }; diff --git a/src/ui/src/hooks/useExternalApps.tsx b/src/ui/src/hooks/useExternalApps.tsx new file mode 100644 index 000000000..d4eaca32e --- /dev/null +++ b/src/ui/src/hooks/useExternalApps.tsx @@ -0,0 +1,186 @@ +import { createElement, ReactNode, useCallback, useEffect, useState } from 'react'; +import { + Outlet, + Route, + RouteObject, + RouteProps, + RouterProps, + RouterProviderProps, + RoutesProps, +} from 'react-router-dom'; +// import { createExternalAppProvider } from '@os/apps/utils/createExternalAppProvider'; +// import { useRecoilState, useRecoilValue } from 'recoil'; +// import { phoneState } from '@os/phone/hooks/state'; + +import { + __federation_method_getRemote, + __federation_method_setRemote, + __federation_method_unwrapDefault, + // @ts-ignore - This is virtual modules from the vite plugin. I guess they forgot to add defs. +} from '__federation__'; +import { useSetRoutes } from './useRouter'; + +interface IApp { + id: string; + path: string; + Icon: ReactNode; + Component: (props: { settings: unknown }) => ReactNode; + router: RouterProviderProps['router']; + routes: RouteObject[]; + name: string; +} + +const useExternalAppsAction = () => { + const loadScript = async (url: string) => { + await new Promise((resolve, reject) => { + const element = document.createElement('script'); + + element.src = url; + element.type = 'text/javascript'; + element.async = true; + + document.head.appendChild(element); + + element.onload = (): void => { + resolve(true); + }; + + element.onerror = (error) => { + element.parentElement?.removeChild(element); + reject(error); + }; + }); + }; + + const generateAppConfig = useCallback(async (appName: string): Promise => { + try { + const IN_GAME = false; + const url = IN_GAME + ? `https://cfx-nui-${appName}/web/dist/remoteEntry.js` + : 'http://localhost:4173/remoteEntry.js'; + const scope = appName; + + await loadScript(url); + + console.log(`Loading external app "${appName}" .. `); + + __federation_method_setRemote(scope, { + url: () => Promise.resolve(url), + format: 'esm', + from: 'vite', + }); + + const mWrapper = await __federation_method_getRemote(scope, './config'); + const m = await __federation_method_unwrapDefault(mWrapper); + + const appConfig = m(); + const config = appConfig; + config.Component = (props: object) => createElement(config.app, props); + config.ico = (props: object) => createElement(config.icon, props); + // config.Component = () => hi; + // const Provider = createExternalAppProvider(config); + + config.Route = (props: any) => { + return ( + // + + // + ); + }; + + config.Icon = createElement(config.icon); + config.NotificationIcon = config.notificationIcon; + + console.log({ config }); + + console.debug(`Successfully loaded external app "${appName}"`); + return config; + } catch (error: unknown) { + console.error( + `Failed to load external app "${appName}". Make sure it is started before NPWD.`, + ); + console.error(error); + + return null; + } + }, []); + + const getConfigs = useCallback( + async (externalApps: string[], existingApps: IApp[]) => { + const appAlreadyLoaded = (appName: string) => { + return existingApps.find((app) => app?.id === appName); + }; + + const configs = await Promise.all( + externalApps.map(async (appName) => { + return appAlreadyLoaded(appName) ?? (await generateAppConfig(appName)); + }), + ); + + return configs; + }, + [generateAppConfig], + ); + + return { + getConfigs, + }; +}; + +const extendRoutesWithApp = (routes: RouteObject[], app: IApp) => { + const [baseRoute, ...restRoutes] = routes; + + if (!baseRoute) { + throw new Error('No base route provided'); + } + + const appsRoute = baseRoute.children?.find((route) => route.path === 'apps'); + if (!appsRoute) { + throw new Error('No route found for app'); + } + + /** Check if route is already there. */ + const appRoute = appsRoute.children?.find((route) => route.path === app.path.replace('/', '')); + + if (appRoute) { + console.log('App route already exists'); + return routes; + } + + appsRoute.children = [ + ...(appsRoute.children ?? []), + { + path: app.path.replace('/', ''), + element: , + children: [...(app.routes[0].children ?? [])], + }, + ]; + + console.log({ routes: app.routes, children: app.routes[0].children }); + + console.log({ appsRoute }); + + return [baseRoute, ...restRoutes]; +}; + +export const useExternalApps = () => { + const [apps, setApps] = useState<(IApp | null)[]>([]); + const { getConfigs } = useExternalAppsAction(); + const setRoutes = useSetRoutes(); + + useEffect(() => { + getConfigs( + ['send_app'], + apps.filter((app) => app !== null), + ).then(setApps); + }, [setApps, getConfigs]); + + useEffect(() => { + apps.forEach((app) => { + if (!app) return; + setRoutes((routes) => extendRoutesWithApp(routes, app)); + }); + }, [setRoutes]); + + return apps.filter((app) => app); +}; diff --git a/src/ui/src/hooks/useRouter.ts b/src/ui/src/hooks/useRouter.ts new file mode 100644 index 000000000..fd4296535 --- /dev/null +++ b/src/ui/src/hooks/useRouter.ts @@ -0,0 +1,10 @@ +import { RouterContext } from '@/RouterContext'; +import { useContext } from 'react'; +import { RouteObject } from 'react-router'; + +export const useSetRoutes = () => { + const { setRoutes, routes } = useContext(RouterContext); + return (callback: (previousRoutes: RouteObject[]) => RouteObject[]) => { + setRoutes(callback(routes)); + }; +}; diff --git a/src/ui/src/main.tsx b/src/ui/src/main.tsx index fee540be8..0724c9071 100644 --- a/src/ui/src/main.tsx +++ b/src/ui/src/main.tsx @@ -1,15 +1,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App.js'; import './index.css'; -import { router } from './Router'; -import { RouterProvider } from 'react-router-dom'; import { Providers } from './Providers.jsx'; createRoot(document.getElementById('root')!).render( - - - + , ); diff --git a/src/ui/src/Router.tsx b/src/ui/src/routes.tsx similarity index 98% rename from src/ui/src/Router.tsx rename to src/ui/src/routes.tsx index e783c4696..3abc49808 100644 --- a/src/ui/src/Router.tsx +++ b/src/ui/src/routes.tsx @@ -13,7 +13,7 @@ import { MessagesApp } from './Apps/Calls/Messages'; import { Conversation } from './Apps/Calls/Messages/Conversation'; import { NewContactView } from './Apps/Calls/Contacts/New'; -export const router = createHashRouter([ +export const routes = [ { path: '/', element: , @@ -106,4 +106,4 @@ export const router = createHashRouter([ }, ], }, -]); +]; diff --git a/src/ui/src/utils/theme.ts b/src/ui/src/utils/theme.ts index c50b5506c..854fec292 100644 --- a/src/ui/src/utils/theme.ts +++ b/src/ui/src/utils/theme.ts @@ -22,9 +22,9 @@ export const setTheme = (theme: Theme) => { * Set class on body. */ if (theme.type === 'dark') { - document.body.classList.add('dark'); + document.documentElement.classList.add('dark'); } else { - document.body.classList.remove('dark'); + document.documentElement.classList.remove('dark'); } localStorage.setItem('theme', JSON.stringify(theme)); diff --git a/src/ui/src/views/Home/index.tsx b/src/ui/src/views/Home/index.tsx index f0f2f88cc..d23f6c07d 100644 --- a/src/ui/src/views/Home/index.tsx +++ b/src/ui/src/views/Home/index.tsx @@ -1,57 +1,58 @@ -import { useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { useExternalApps } from '@/hooks/useExternalApps'; +import { createElement, useRef, useState } from 'react'; +import { Link, Router } from 'react-router-dom'; /** List of apps */ const apps = [ { id: 'calls', name: 'Calls', - logo: '📞', + Icon: '📞', }, { id: 'casino', name: 'Casino', - logo: '🎰', + Icon: '🎰', }, { id: 'messages', name: 'Messages', - logo: '💬', + Icon: '💬', }, { id: 'email', name: 'Email', - logo: '📧', + Icon: '📧', }, { id: 'photos', name: 'Photos', - logo: '📷', + Icon: '📷', }, { id: 'camera', name: 'Camera', - logo: '📸', + Icon: '📸', }, { id: 'clock', name: 'Clock', - logo: '⏰', + Icon: '⏰', }, { id: 'calendar', name: 'Calendar', - logo: '📅', + Icon: '📅', }, { id: 'notes', name: 'Notes', - logo: '📝', + Icon: '📝', }, { id: 'settings', name: 'Settings', - logo: '⚙️', + Icon: '⚙️', }, ]; @@ -59,6 +60,10 @@ export const HomeView = () => { const [isEditing, setIsEditing] = useState(false); const timeoutRef = useRef(null); + const externalApps = useExternalApps(); + + console.log({ externalApps }); + /** * Check if user is holding long click */ @@ -79,6 +84,9 @@ export const HomeView = () => { } }; + const FirstExternalApp = externalApps[0]; + const Component = FirstExternalApp?.Component; + return (
{
{apps.map((app) => ( -
- {app.logo} +
+ {app.Icon}
))} + + {externalApps.map((app) => + app ? ( + +
+ {app.Icon} +
+ + ) : null, + )}
); diff --git a/src/ui/tailwind.config.js b/src/ui/tailwind.config.js index 483d9c833..a0d44432b 100644 --- a/src/ui/tailwind.config.js +++ b/src/ui/tailwind.config.js @@ -1,70 +1,70 @@ /** @type {import('tailwindcss').Config} */ export default { content: ['./src/**/*.{js,jsx,ts,tsx}', '../../packages/ui/**/*.{js,jsx,ts,tsx}'], - darkMode: ['class', 'class'], - plugins: [require("tailwindcss-animate")], + darkMode: 'class', + plugins: [require('tailwindcss-animate')], extend: { fontFamily: { sans: ['var(--font-geist-sans)'], }, }, theme: { - extend: { - backgroundColor: { - primary: 'var(--bg-primary)', - secondary: 'var(--bg-secondary)' - }, - textColor: { - primary: 'var(--text-primary)', - secondary: 'var(--text-secondary)' - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - } - } + extend: { + backgroundColor: { + primary: 'var(--bg-primary)', + secondary: 'var(--bg-secondary)', + }, + textColor: { + primary: 'var(--text-primary)', + secondary: 'var(--text-secondary)', + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + }, + }, + }, }, }; diff --git a/src/ui/vite.config.js b/src/ui/vite.config.js index 52e0afabb..4b31106af 100644 --- a/src/ui/vite.config.js +++ b/src/ui/vite.config.js @@ -1,10 +1,21 @@ import path from 'path'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import federation from '@originjs/vite-plugin-federation'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + federation({ + name: 'host-app', + remotes: { dummyRemote: 'dummyRemote.js' }, + shared: ['react', 'react-dom', '@emotion/react', 'react-router-dom', 'fivem-nui-react-lib'], + exposes: { + './Input': './src/ui/components/Input.tsx', + }, + }), + ], base: './', build: { emptyOutDir: true,