Project Init

This commit is contained in:
Muluhabt
2026-05-29 15:23:46 +03:00
commit 2fbc557aac
67387 changed files with 6063341 additions and 0 deletions

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM deps AS base
COPY . .
FROM base AS portal-build
RUN npm run build:portal
FROM base AS backoffice-build
RUN npm run build:backoffice
FROM nginx:1.29-alpine AS portal
COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html
COPY nginx/portal.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
FROM nginx:1.29-alpine AS backoffice
COPY --from=backoffice-build /app/dist/apps/backoffice /usr/share/nginx/html
COPY nginx/backoffice.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# EMA Platform — Nx Monorepo Frontend
A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and Portal) with shared libraries for API integration, UI components, and theming.
---
## Tech Stack
| Tool | Version |
|------|---------|
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
---
## Monorepo Structure
```
emaui/
├── apps/
│ ├── backoffice/ # Admin/operator SPA — port 4201
│ └── portal/ # End-user SPA — port 4200
└── libs/
├── api/ # RTK Query baseApi, session resolution, generic query/mutation hooks
├── ui/ # Shared Mantine components (ConfirmModal, ApiErrorAlert, notify)
└── shared/ # Mantine theme (emaTheme), design tokens
```
### libs/api
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or localStorage.
- `session/``resolveTokenFromStorage()` reads from `localStorage` keys or `auth-token` cookie. `resolveSessionContext()` merges Redux state token with storage fallback.
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
### libs/ui
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
### libs/shared
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
---
## Auth Flow
1. User submits the login form (LoginForm / LoginPage).
2. The form calls the `login` RTK Query mutation (backoffice) or a plain `fetch` (portal).
3. On success, `loginSuccess` action is dispatched → Redux `auth` slice stores `token` and `user`; `authStorage.setToken()` persists the token to `localStorage`.
4. `baseApi`'s `prepareHeaders` reads the token via `resolveSessionContext(getState())` and attaches `Authorization: Bearer <token>` to every RTK Query request.
5. `ProtectedRoute` checks `localStorage` for the token key on every navigation — if absent, redirects to `/login`.
6. `logout` action clears Redux state and calls `authStorage.clear()` to remove all localStorage keys.
---
## Local Setup
```bash
# 1. Install dependencies
npm install
# 2. Copy environment config
cp .env.example .env
# Edit VITE_BASE_API_URL to point at your running backend
# 3. Start the backoffice (port 4201)
npm run backoffice
# 4. Start the portal (port 4200)
npm run portal
# 5. Or start both in parallel
npm run dev:all
```
---
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
---
## Docker
```bash
# Build and run both apps via Docker Compose
cp .env.example .env
docker compose up --build
```
The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoffice`). Each stage produces an nginx image serving the built SPA.
---
## Adding a New Feature
1. Create the feature folder under the relevant app:
```
apps/backoffice/src/app/features/<feature-name>/
├── types/ # TypeScript interfaces
├── api/ # RTK Query injectEndpoints
├── store/ # Redux slice (if local state needed)
├── hooks/ # Custom hooks wrapping store/api
├── components/ # Presentational React components
└── pages/ # Route-level components
```
2. Wire up the API endpoint in `<feature-name>/api/<feature>-api.ts` using `baseApi.injectEndpoints(...)`.
3. Add a route in `apps/<app>/src/app/router/index.tsx` (backoffice) or `apps/<app>/src/app/router.tsx` (portal).
4. Add a sidebar entry in `AppSidebar.tsx` (backoffice only) for the new route.
5. If the feature needs shared UI, add components to `libs/ui/` and export from `libs/ui/src/index.ts`.

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMA Backoffice</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{
"name": "@ema-platform/backoffice",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/backoffice/src",
"projectType": "application",
"targets": {
"serve": {
"executor": "@nx/vite:dev-server",
"options": { "buildTarget": "@ema-platform/backoffice:build" }
}
},
"tags": []
}

View File

@@ -0,0 +1,10 @@
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
export function App() {
return (
<AppProviders>
<AppRouter />
</AppProviders>
);
}

View File

@@ -0,0 +1,28 @@
import { baseApi } from '@ema-platform/api';
import type { LoginPayload } from '../types/auth.types';
interface LoginArgs {
email: string;
password: string;
}
interface RefreshArgs {
refreshToken: string;
}
const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginPayload, LoginArgs>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
}),
refresh: builder.mutation<{ token: string }, RefreshArgs>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
logout: builder.mutation<{ message: string }, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useRefreshMutation, useLogoutMutation } = authApi;

View File

@@ -0,0 +1,79 @@
import {
TextInput,
PasswordInput,
Button,
Stack,
Title,
Paper,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useLoginMutation } from '../api/auth-api';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof schema>;
export function LoginForm() {
const navigate = useNavigate();
const { login } = useAuth();
const [loginMutate, { isLoading }] = useLoginMutation();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
try {
const result = await loginMutate(values).unwrap();
login(result);
navigate('/dashboard');
} catch {
notify.error('Invalid email or password');
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Sign in</Title>
<Text c="dimmed" size="sm">
Enter your credentials to access the backoffice
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,17 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (payload: LoginPayload) => dispatch(loginSuccess(payload)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -0,0 +1,5 @@
import { LoginForm } from '../components/LoginForm';
export function LoginPage() {
return <LoginForm />;
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
}
},
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -0,0 +1,19 @@
export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
}

View File

@@ -0,0 +1,28 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-backoffice-auth-token',
refreshToken: 'ema-backoffice-refresh-token',
user: 'ema-backoffice-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
removeToken: () => localStorage.removeItem(KEYS.token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (token: string) => localStorage.setItem(KEYS.refreshToken, token),
getUser: (): AuthUser | null => {
const raw = localStorage.getItem(KEYS.user);
try {
return raw ? (JSON.parse(raw) as AuthUser) : null;
} catch {
return null;
}
},
setUser: (user: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(user)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -0,0 +1,44 @@
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
interface StatCardProps {
label: string;
value: string;
icon: React.ElementType;
color: string;
}
function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
return (
<Paper p="md" shadow="sm" radius="md" withBorder>
<Group justify="space-between">
<Stack gap={4}>
<Text size="sm" c="dimmed">
{label}
</Text>
<Title order={3}>{value}</Title>
</Stack>
<Icon size={32} color={color} />
</Group>
</Paper>
);
}
export function DashboardPage() {
return (
<Stack gap="lg">
<Title order={2}>Dashboard</Title>
<Grid>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Items" value="—" icon={IconBox} color="#2563eb" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Users" value="—" icon={IconUsers} color="#16a34a" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Activity" value="—" icon={IconActivity} color="#d97706" />
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
import { baseApi } from '@ema-platform/api';
export interface Item {
id: string;
name: string;
description: string | null;
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
createdAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
const itemApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getItems: builder.query<PaginatedResponse<Item>, { page?: number; limit?: number }>({
query: ({ page = 1, limit = 20 } = {}) => ({
url: '/items',
params: { page, limit },
}),
providesTags: ['Api'],
}),
createItem: builder.mutation<Item, { name: string; description?: string }>({
query: (body) => ({ url: '/items', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
deleteItem: builder.mutation<void, string>({
query: (id) => ({ url: `/items/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const { useGetItemsQuery, useCreateItemMutation, useDeleteItemMutation } = itemApi;

View File

@@ -0,0 +1,60 @@
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
import { notify } from '@ema-platform/ui';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
};
export function ItemTable() {
const { data, isLoading } = useGetItemsQuery({});
const [deleteItem] = useDeleteItemMutation();
const handleDelete = async (id: string) => {
try {
await deleteItem(id).unwrap();
notify.success('Item deleted');
} catch {
notify.error('Failed to delete item');
}
};
if (isLoading) return <Text>Loading...</Text>;
if (!data?.data.length) return <Text c="dimmed">No items found.</Text>;
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.data.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
</Table.Td>
<Table.Td>{new Date(item.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleDelete(item.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,13 @@
import { Stack, Title, Paper } from '@mantine/core';
import { ItemTable } from '../components/ItemTable';
export function ItemPage() {
return (
<Stack gap="lg">
<Title order={2}>Items</Title>
<Paper p="md" shadow="sm" radius="md" withBorder>
<ItemTable />
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,12 @@
import { Center, Box } from '@mantine/core';
import { Outlet } from 'react-router-dom';
export function AuthLayout() {
return (
<Center h="100vh" bg="gray.0">
<Box w={420}>
<Outlet />
</Box>
</Center>
);
}

View File

@@ -0,0 +1,27 @@
import { AppShell } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet } from 'react-router-dom';
import { AppHeader } from './components/AppHeader';
import { AppSidebar } from './components/AppSidebar';
export function BackofficeLayout() {
const [opened, { toggle }] = useDisclosure();
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 240, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<AppHeader onToggle={toggle} />
</AppShell.Header>
<AppShell.Navbar>
<AppSidebar />
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,32 @@
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
import { IconLogout } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../features/auth/hooks/useAuth';
interface AppHeaderProps {
onToggle: () => void;
}
export function AppHeader({ onToggle }: AppHeaderProps) {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger onClick={onToggle} size="sm" hiddenFrom="sm" />
<Text fw={700} size="lg">
EMA Backoffice
</Text>
</Group>
<ActionIcon variant="subtle" onClick={handleLogout} title="Logout">
<IconLogout size={18} />
</ActionIcon>
</Group>
);
}

View File

@@ -0,0 +1,27 @@
import { NavLink, Stack } from '@mantine/core';
import { IconDashboard, IconBox } from '@tabler/icons-react';
import { useNavigate, useLocation } from 'react-router-dom';
const NAV_ITEMS = [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Items', icon: IconBox, path: '/items' },
];
export function AppSidebar() {
const navigate = useNavigate();
const { pathname } = useLocation();
return (
<Stack p="xs" gap={4}>
{NAV_ITEMS.map(({ label, icon: Icon, path }) => (
<NavLink
key={path}
label={label}
leftSection={<Icon size={18} />}
active={pathname.startsWith(path)}
onClick={() => navigate(path)}
/>
))}
</Stack>
);
}

View File

@@ -0,0 +1,21 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 1000 * 60 * 5 },
},
});
export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</QueryClientProvider>
</Provider>
);
}

View File

@@ -0,0 +1,13 @@
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { emaTheme } from '@ema-platform/shared';
import type { ReactNode } from 'react';
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme}>
<Notifications position="top-right" />
{children}
</MantineProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { Navigate, Outlet } from 'react-router-dom';
function getTokenFromCookie(): string | undefined {
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export function ProtectedRoute() {
const token =
localStorage.getItem('ema-backoffice-auth-token') ?? getTokenFromCookie();
if (!token) return <Navigate to="/login" replace />;
return <Outlet />;
}

View File

@@ -0,0 +1,33 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { ProtectedRoute } from './ProtectedRoute';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { AuthLayout } from '../layouts/AuthLayout';
import { LoginPage } from '../features/auth/pages/LoginPage';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import { ItemPage } from '../features/item/pages/ItemPage';
const router = createBrowserRouter([
{
element: <ProtectedRoute />,
children: [
{
element: <BackofficeLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/items', element: <ItemPage /> },
],
},
],
},
{
element: <AuthLayout />,
children: [{ path: '/login', element: <LoginPage /> }],
},
{ path: '/404', element: <div>Page not found</div> },
{ path: '*', element: <Navigate to="/404" replace /> },
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(selector: (state: RootState) => T) =>
useSelector(selector);

View File

@@ -0,0 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import './styles.css';
import { App } from './app/app';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
*, *::before, *::after { box-sizing: border-box; }

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/apps/backoffice",
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 4201,
host: 'localhost',
},
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},
build: {
outDir: '../../dist/apps/backoffice',
emptyOutDir: true,
reportCompressedSize: true,
},
});

12
apps/portal/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMA Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

13
apps/portal/project.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@ema-platform/portal",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/portal/src",
"projectType": "application",
"targets": {
"serve": {
"executor": "@nx/vite:dev-server",
"options": { "buildTarget": "@ema-platform/portal:build" }
}
},
"tags": []
}

View File

@@ -0,0 +1,10 @@
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
export function App() {
return (
<AppProviders>
<AppRouter />
</AppProviders>
);
}

View File

@@ -0,0 +1,17 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (p: LoginPayload) => dispatch(loginSuccess(p)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const res = await fetch(`${BASE_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Login failed');
const data = (await res.json()) as Parameters<typeof login>[0];
login(data);
navigate('/dashboard');
} catch {
notify.error('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Sign in to Portal</Title>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
}
},
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -0,0 +1,19 @@
export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
}

View File

@@ -0,0 +1,23 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-portal-auth-token',
refreshToken: 'ema-portal-refresh-token',
user: 'ema-portal-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
getUser: (): AuthUser | null => {
try {
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
} catch {
return null;
}
},
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -0,0 +1,14 @@
import { Stack, Title, Text, Paper } from '@mantine/core';
export function DashboardPage() {
return (
<Stack gap="lg">
<Title order={2}>My Dashboard</Title>
<Paper p="md" shadow="sm" radius="md" withBorder>
<Text c="dimmed">
Welcome to the EMA Portal. Your content will appear here.
</Text>
</Paper>
</Stack>
);
}

View File

@@ -0,0 +1,36 @@
import { AppShell, Group, Text, Button } from '@mantine/core';
import { Outlet, useNavigate } from 'react-router-dom';
export function PortalLayout() {
const navigate = useNavigate();
const isLoggedIn = !!localStorage.getItem('ema-portal-auth-token');
return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Text fw={700}>EMA Portal</Text>
{isLoggedIn ? (
<Button
variant="subtle"
size="sm"
onClick={() => {
localStorage.removeItem('ema-portal-auth-token');
navigate('/login');
}}
>
Logout
</Button>
) : (
<Button variant="subtle" size="sm" onClick={() => navigate('/login')}>
Login
</Button>
)}
</Group>
</AppShell.Header>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,19 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 1000 * 60 * 5 } },
});
export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</QueryClientProvider>
</Provider>
);
}

View File

@@ -0,0 +1,13 @@
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { emaTheme } from '@ema-platform/shared';
import type { ReactNode } from 'react';
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme}>
<Notifications position="top-right" />
{children}
</MantineProvider>
);
}

View File

@@ -0,0 +1,37 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { PortalLayout } from './layouts/PortalLayout';
import { LoginPage } from './features/auth/pages/LoginPage';
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
function getToken(): string | null {
return localStorage.getItem('ema-portal-auth-token');
}
function ProtectedRoute({ children }: { children: ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <>{children}</>;
}
const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{
element: <PortalLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{
path: '/dashboard',
element: (
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
),
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,6 @@
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector = <T>(selector: (state: RootState) => T) =>
useSelector(selector);

View File

@@ -0,0 +1,15 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

15
apps/portal/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './styles.css';
import { App } from './app/app';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc/apps/portal",
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
preview: { port: 4200, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},
build: {
outDir: '../../dist/apps/portal',
emptyOutDir: true,
reportCompressedSize: true,
},
});

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: "3.9"
services:
portal:
build:
context: .
dockerfile: Dockerfile
target: portal
ports:
- "${PORTAL_PORT:-4200}:80"
env_file: .env
backoffice:
build:
context: .
dockerfile: Dockerfile
target: backoffice
ports:
- "${BACKOFFICE_PORT:-4201}:80"
env_file: .env

34
eslint.config.mjs Normal file
View File

@@ -0,0 +1,34 @@
import { FlatCompat } from '@eslint/eslintrc';
import nxEslintPlugin from '@nx/eslint-plugin';
import js from '@eslint/js';
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
});
export default [
{ plugins: { '@nx': nxEslintPlugin } },
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?js$'],
depConstraints: [
{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] },
],
},
],
},
},
...compat.config({
extends: ['plugin:@nx/typescript'],
}).map((c) => ({ ...c, files: ['**/*.ts', '**/*.tsx'] })),
...compat.config({
extends: ['plugin:@nx/javascript'],
}).map((c) => ({ ...c, files: ['**/*.js', '**/*.jsx'] })),
{ ignores: ['**/dist/**', '**/build/**', '**/.react-router/**'] },
];

BIN
libs/.DS_Store vendored Normal file

Binary file not shown.

7
libs/api/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/src",
"projectType": "library",
"tags": []
}

3
libs/api/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './lib/base-api';
export * from './lib/query-and-mutation';
export * from './lib/session';

View File

@@ -0,0 +1,23 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { resolveSessionContext } from '../session';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000';
export const baseApi = createApi({
reducerPath: 'baseApi',
baseQuery: fetchBaseQuery({
baseUrl: BASE_API_URL,
prepareHeaders: (headers, { getState }) => {
const { token, sessionHeaders } = resolveSessionContext(
getState() as { auth?: { token?: string } },
);
if (token) headers.set('Authorization', `Bearer ${token}`);
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
return headers;
},
}),
tagTypes: ['Api'],
endpoints: () => ({}),
});

View File

@@ -0,0 +1,44 @@
import { baseApi } from '../base-api';
export type ApiQueryArgs = {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
params?: Record<string, unknown>;
body?: unknown;
headers?: Record<string, string>;
cacheKey?: string | unknown[];
};
const queryApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
apiQuery: builder.query<unknown, ApiQueryArgs>({
query: ({ url, params }) => ({ url, params }),
}),
apiMutation: builder.mutation<unknown, ApiQueryArgs>({
query: ({ url, method = 'POST', body, headers }) => ({
url,
method,
body,
headers,
}),
}),
}),
overrideExisting: false,
});
export const { useApiQueryQuery, useApiMutationMutation } = queryApi;
export function useApiQuery<TData = unknown>(
args: ApiQueryArgs,
options?: Parameters<typeof useApiQueryQuery>[1],
) {
return useApiQueryQuery(args, options) as ReturnType<typeof useApiQueryQuery> & {
data: TData | undefined;
};
}
export function useApiMutation<TData = unknown>() {
return useApiMutationMutation() as ReturnType<typeof useApiMutationMutation> & {
data: TData | undefined;
};
}

View File

@@ -0,0 +1,29 @@
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
} as const;
const TOKEN_STORAGE_KEYS = [
'ema-backoffice-auth-token',
'ema-portal-auth-token',
'auth-token',
] as const;
export function resolveTokenFromStorage(): string | undefined {
for (const key of TOKEN_STORAGE_KEYS) {
const stored = localStorage.getItem(key);
if (stored) return stored;
}
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export function resolveSessionContext(state?: { auth?: { token?: string } }): {
token: string | undefined;
sessionHeaders: Record<string, string>;
} {
const token = state?.auth?.token ?? resolveTokenFromStorage();
return { token, sessionHeaders: {} };
}

5
libs/api/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

7
libs/shared/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/shared",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/src",
"projectType": "library",
"tags": []
}

1
libs/shared/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './lib/theme/ema-theme';

View File

@@ -0,0 +1,34 @@
import { createTheme, type MantineColorsTuple } from '@mantine/core';
const emaPrimary: MantineColorsTuple = [
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
];
const emaSecondary: MantineColorsTuple = [
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
];
export const emaTheme = createTheme({
primaryColor: 'emaPrimary',
colors: {
emaPrimary,
emaSecondary,
},
fontFamily: 'Inter, sans-serif',
defaultRadius: 'md',
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
shadows: {
xs: '0 1px 3px rgba(0,0,0,0.05)',
sm: '0 1px 5px rgba(0,0,0,0.07)',
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

7
libs/ui/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/ui",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/src",
"projectType": "library",
"tags": []
}

3
libs/ui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';

View File

@@ -0,0 +1,24 @@
import { Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
interface ApiErrorAlertProps {
error: unknown;
title?: string;
}
export function ApiErrorAlert({ error, title = 'An error occurred' }: ApiErrorAlertProps) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null && 'data' in error
? String(
(error as { data: { message?: string } }).data?.message ?? 'Unknown error',
)
: 'Something went wrong. Please try again.';
return (
<Alert icon={<IconAlertCircle size={16} />} title={title} color="red" variant="light">
{message}
</Alert>
);
}

View File

@@ -0,0 +1,39 @@
import { Modal, Button, Group, Text } from '@mantine/core';
interface ConfirmModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
loading?: boolean;
}
export function ConfirmModal({
opened,
onClose,
onConfirm,
title = 'Confirm action',
message = 'Are you sure you want to proceed?',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
loading = false,
}: ConfirmModalProps) {
return (
<Modal opened={opened} onClose={onClose} title={title} size="sm" centered>
<Text size="sm" mb="xl">
{message}
</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</Group>
</Modal>
);
}

View File

@@ -0,0 +1,12 @@
import { notifications } from '@mantine/notifications';
export const notify = {
success: (message: string, title = 'Success') =>
notifications.show({ title, message, color: 'green' }),
error: (message: string, title = 'Error') =>
notifications.show({ title, message, color: 'red' }),
info: (message: string, title = 'Info') =>
notifications.show({ title, message, color: 'blue' }),
warning: (message: string, title = 'Warning') =>
notifications.show({ title, message, color: 'yellow' }),
};

5
libs/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

12
nginx/backoffice.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

12
nginx/portal.conf Normal file
View File

@@ -0,0 +1,12 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

BIN
node_modules/.DS_Store generated vendored Normal file

Binary file not shown.

1
node_modules/.bin/acorn generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../acorn/bin/acorn

1
node_modules/.bin/autoprefixer generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../autoprefixer/bin/autoprefixer

1
node_modules/.bin/baseline-browser-mapping generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.cjs

1
node_modules/.bin/browserslist generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../browserslist/cli.js

1
node_modules/.bin/cssesc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../cssesc/bin/cssesc

1
node_modules/.bin/detect generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../detect-port/dist/commonjs/bin/detect-port.js

1
node_modules/.bin/detect-port generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../detect-port/dist/commonjs/bin/detect-port.js

1
node_modules/.bin/ejs generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../ejs/bin/cli.js

1
node_modules/.bin/esbuild generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esbuild/bin/esbuild

1
node_modules/.bin/eslint generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../eslint/bin/eslint.js

1
node_modules/.bin/esparse generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esparse.js

1
node_modules/.bin/esvalidate generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esvalidate.js

1
node_modules/.bin/flat generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../flat/cli.js

1
node_modules/.bin/he generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../he/bin/he

1
node_modules/.bin/http-server generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../http-server/bin/http-server

1
node_modules/.bin/is-docker generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../is-docker/cli.js

1
node_modules/.bin/jake generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jake/bin/cli.js

1
node_modules/.bin/jiti generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jiti/lib/jiti-cli.mjs

1
node_modules/.bin/js-yaml generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@zkochan/js-yaml/bin/js-yaml.js

1
node_modules/.bin/jsesc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../jsesc/bin/jsesc

1
node_modules/.bin/json5 generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../json5/lib/cli.js

1
node_modules/.bin/loose-envify generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../loose-envify/cli.js

1
node_modules/.bin/mf generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@module-federation/cli/bin/mf.js

1
node_modules/.bin/mime generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mime/cli.js

1
node_modules/.bin/mini-svg-data-uri generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../mini-svg-data-uri/cli.js

1
node_modules/.bin/nanoid generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs

1
node_modules/.bin/node-which generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../which/bin/node-which

1
node_modules/.bin/nx generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../nx/dist/bin/nx.js

Some files were not shown because too many files have changed in this diff Show More