feat(web): Phase 3a — migrate CRA to Vite + make the app runnable
The frontend was a Create React App scaffold (deprecated) where App.tsx was still the stock 'Learn React' boilerplate — the implemented login/register pages and stores were never mounted (no router). This swaps to Vite/TS5, fixes the broken API client, wires a real router + dashboard, and lands on a runnable app. Tooling swap (CRA -> Vite): * Dropped react-scripts, web-vitals, @types/jest; added vite 6 + @vitejs/plugin-react + vitest 3 + jsdom; bumped typescript 4.9 -> 5.6, @types/node 16 -> 22. * New vite.config.ts (with dev-server /api proxy -> backend, no CORS in dev), tsconfig.json (target es2022, moduleResolution bundler, vite/client types), tsconfig.node.json, vite-env.d.ts, root index.html. * .env / .env.example (VITE_API_URL + VITE_API_TARGET). Renamed index.tsx -> main.tsx (theme provider + CssBaseline). Deleted all CRA boilerplate (App.css, logo.svg, App.test.tsx, reportWebVitals.ts, react-app-env.d.ts, CRA index.html/logos/readme) + the orphaned empty web/src/ and mobile/ trees. * node_modules 479MB -> 265MB; lockfile 695KB -> 198KB. API client correctness (services/api.ts): * Env var process.env.REACT_APP_* -> import.meta.env.VITE_*; dropped the hardcoded http://solaria:8001/api (now /api via the dev proxy). * Fixed contract mismatches: getCurrentUser /auth/me -> /users/me; updateMedication PUT -> POST /:id; deleteMedication DELETE -> POST /:id/delete. * Added refreshToken() (/auth/refresh) and server-side logout() (/auth/logout). * Removed the /lab-results block (no backend route). * 401 interceptor now attempts ONE silent refresh (deduped) before bouncing to /login, instead of hard-redirecting on every 401. * CreateMedicationRequest type now matches the backend (required route + profile_id fields). Router + dashboard: * Real App.tsx with BrowserRouter: /login, /register (public), / (protected Dashboard), catch-all -> /. * New Dashboard.tsx: MUI AppBar + welcome card, loads the current user on mount, logout button. The shell feature UIs get built into next. * LoginPage/RegisterPage navigate to / (was /dashboard); store logout is now async; AuthTokens type includes refresh_token. * Minimal MUI theme.ts. Verified: npm run build clean (TS5 strict + Vite), dev server serves the app + proxies /api to the backend, vitest runs. Solaria round-trip confirmed all corrected endpoints (/users/me 200, /auth/refresh 200, /auth/logout 204, medication create 200).
This commit is contained in:
parent
8bc0391100
commit
cd5a62c983
28 changed files with 2104 additions and 15710 deletions
8
web/normogen-web/.env.example
Normal file
8
web/normogen-web/.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Copy to .env and adjust per environment.
|
||||||
|
|
||||||
|
# API base URL the app calls. In dev, "/api" routes through the Vite proxy.
|
||||||
|
VITE_API_URL=/api
|
||||||
|
|
||||||
|
# Dev-only: target the Vite proxy forwards /api to.
|
||||||
|
# Use http://solaria:6500 to develop against the Solaria deployment.
|
||||||
|
VITE_API_TARGET=http://localhost:6500
|
||||||
8
web/normogen-web/.gitignore
vendored
8
web/normogen-web/.gitignore
vendored
|
|
@ -8,16 +8,16 @@
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# production
|
# production (Vite output)
|
||||||
|
/dist
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.*.local
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# Getting Started with Create React App
|
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|
||||||
|
|
||||||
The page will reload if you make edits.\
|
|
||||||
You will also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `npm run eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
|
||||||
|
|
||||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
|
||||||
|
|
||||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
15
web/normogen-web/index.html
Normal file
15
web/normogen-web/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1976d2" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>Normogen</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17131
web/normogen-web/package-lock.json
generated
17131
web/normogen-web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,52 +2,37 @@
|
||||||
"name": "normogen-web",
|
"name": "normogen-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"@mui/x-charts": "^8.27.4",
|
"@mui/x-charts": "^8.27.4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^13.5.0",
|
|
||||||
"@types/jest": "^27.5.2",
|
|
||||||
"@types/node": "^16.18.126",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"typescript": "^4.9.5",
|
|
||||||
"web-vitals": "^2.1.4",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"devDependencies": {
|
||||||
"start": "react-scripts start",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"build": "react-scripts build",
|
"@testing-library/react": "^16.3.2",
|
||||||
"test": "react-scripts test",
|
"@types/node": "^22.10.0",
|
||||||
"eject": "react-scripts eject"
|
"@types/react": "^19.2.14",
|
||||||
},
|
"@types/react-dom": "^19.2.3",
|
||||||
"eslintConfig": {
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"extends": [
|
"jsdom": "^25.0.1",
|
||||||
"react-app",
|
"typescript": "^5.6.3",
|
||||||
"react-app/jest"
|
"vite": "^6.0.3",
|
||||||
]
|
"vitest": "^3.0.0"
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<title>React App</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
|
|
@ -1,38 +0,0 @@
|
||||||
.App {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-logo {
|
|
||||||
height: 40vmin;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.App-logo {
|
|
||||||
animation: App-logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-header {
|
|
||||||
background-color: #282c34;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: calc(10px + 2vmin);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-link {
|
|
||||||
color: #61dafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes App-logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
|
||||||
render(<App />);
|
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
|
||||||
expect(linkElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +1,31 @@
|
||||||
import React from 'react';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import logo from './logo.svg';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import './App.css';
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { ProtectedRoute } from './components/common/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<BrowserRouter>
|
||||||
<header className="App-header">
|
<Routes>
|
||||||
<img src={logo} className="App-logo" alt="logo" />
|
{/* Public routes */}
|
||||||
<p>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
Edit <code>src/App.tsx</code> and save to reload.
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
</p>
|
|
||||||
<a
|
{/* Protected routes */}
|
||||||
className="App-link"
|
<Route
|
||||||
href="https://reactjs.org"
|
path="/"
|
||||||
target="_blank"
|
element={
|
||||||
rel="noopener noreferrer"
|
<ProtectedRoute>
|
||||||
>
|
<Dashboard />
|
||||||
Learn React
|
</ProtectedRoute>
|
||||||
</a>
|
}
|
||||||
</header>
|
/>
|
||||||
</div>
|
|
||||||
|
{/* Catch-all -> redirect home */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import './index.css';
|
|
||||||
import App from './App';
|
|
||||||
import reportWebVitals from './reportWebVitals';
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
|
||||||
document.getElementById('root') as HTMLElement
|
|
||||||
);
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
||||||
// If you want to start measuring performance in your app, pass a function
|
|
||||||
// to log results (for example: reportWebVitals(console.log))
|
|
||||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
|
||||||
reportWebVitals();
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
15
web/normogen-web/src/main.tsx
Normal file
15
web/normogen-web/src/main.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||||
|
import { theme } from './theme';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
70
web/normogen-web/src/pages/Dashboard.tsx
Normal file
70
web/normogen-web/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect, type FC } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useAuthStore } from '../store/useStore';
|
||||||
|
|
||||||
|
export const Dashboard: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, isAuthenticated, isLoading, loadUser, logout } = useAuthStore();
|
||||||
|
|
||||||
|
// On mount, refresh the user record from the backend (confirms the token is
|
||||||
|
// still valid and pulls the latest profile).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !user) {
|
||||||
|
loadUser();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user, loadUser]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar position="static">
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Normogen
|
||||||
|
</Typography>
|
||||||
|
<Button color="inherit" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||||
|
{isLoading && (
|
||||||
|
<Box display="flex" justifyContent="center" mt={4}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Welcome{user ? `, ${user.username}` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
You're signed in to Normogen.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
This is the dashboard shell. Medication management, health statistics,
|
||||||
|
and drug-interaction checking UIs will be built here.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -24,7 +24,7 @@ export const LoginPage: React.FC = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/dashboard');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by the store
|
// Error is handled by the store
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const RegisterPage: React.FC = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register(username, email, password);
|
await register(username, email, password);
|
||||||
navigate('/dashboard');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by the store
|
// Error is handled by the store
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
web/normogen-web/src/react-app-env.d.ts
vendored
1
web/normogen-web/src/react-app-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
||||||
/// <reference types="react-scripts" />
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { ReportHandler } from 'web-vitals';
|
|
||||||
|
|
||||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
|
||||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
|
||||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
|
||||||
getCLS(onPerfEntry);
|
|
||||||
getFID(onPerfEntry);
|
|
||||||
getFCP(onPerfEntry);
|
|
||||||
getLCP(onPerfEntry);
|
|
||||||
getTTFB(onPerfEntry);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default reportWebVitals;
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
|
@ -13,16 +13,19 @@ import {
|
||||||
HealthStat,
|
HealthStat,
|
||||||
CreateHealthStatRequest,
|
CreateHealthStatRequest,
|
||||||
TrendData,
|
TrendData,
|
||||||
LabResult,
|
ApiError,
|
||||||
ApiError
|
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
// API base URL - change this for production
|
// API base URL. In dev this is "/api", proxied by the Vite dev server to the
|
||||||
const API_BASE = process.env.REACT_APP_API_URL || 'http://solaria:8001/api';
|
// backend (see vite.config.ts). For a production build pointed at a remote
|
||||||
|
// backend, set VITE_API_URL to the absolute URL at build time.
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private client: AxiosInstance;
|
private client: AxiosInstance;
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
private refreshToken: string | null = null;
|
||||||
|
private refreshing: Promise<string | null> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
|
|
@ -32,13 +35,15 @@ class ApiService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load token from localStorage
|
// Load tokens from localStorage (single source of truth shared with the
|
||||||
|
// zustand auth store's persist).
|
||||||
this.token = localStorage.getItem('token');
|
this.token = localStorage.getItem('token');
|
||||||
|
this.refreshToken = localStorage.getItem('refresh_token');
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
this.setAuthHeader(this.token);
|
this.setAuthHeader(this.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request interceptor
|
// Request interceptor: attach the access token.
|
||||||
this.client.interceptors.request.use(
|
this.client.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
|
|
@ -46,28 +51,52 @@ class ApiService {
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Response interceptor for error handling
|
// Response interceptor: on 401, attempt ONE silent refresh; if it fails,
|
||||||
|
// clear credentials and redirect to /login.
|
||||||
this.client.interceptors.response.use(
|
this.client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
const original = error.config as InternalAxiosRequestConfig & {
|
||||||
// Clear token and redirect to login
|
_retried?: boolean;
|
||||||
this.logout();
|
_noRefresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
!original._retried &&
|
||||||
|
!original._noRefresh &&
|
||||||
|
this.refreshToken
|
||||||
|
) {
|
||||||
|
original._retried = true;
|
||||||
|
const newToken = await this.doRefresh();
|
||||||
|
if (newToken) {
|
||||||
|
original.headers!.Authorization = `Bearer ${newToken}`;
|
||||||
|
return this.client.request(original);
|
||||||
|
}
|
||||||
|
// Refresh failed: drop credentials and bounce to login.
|
||||||
|
this.clearCredentials();
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
return Promise.reject(this.handleError(error));
|
return Promise.reject(this.handleError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.reject(this.handleError(error));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(error: AxiosError): ApiError {
|
private handleError(error: AxiosError): ApiError {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const data = error.response.data as any;
|
const data = error.response.data as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
message: data.error || data.message || 'An error occurred',
|
message:
|
||||||
|
(data.error as string) ||
|
||||||
|
(data.message as string) ||
|
||||||
|
'An error occurred',
|
||||||
code: String(error.response.status),
|
code: String(error.response.status),
|
||||||
details: data,
|
details: data,
|
||||||
};
|
};
|
||||||
|
|
@ -88,6 +117,14 @@ class ApiService {
|
||||||
this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTokens(accessToken: string, refreshToken: string) {
|
||||||
|
this.token = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
localStorage.setItem('token', accessToken);
|
||||||
|
localStorage.setItem('refresh_token', refreshToken);
|
||||||
|
this.setAuthHeader(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
setToken(token: string) {
|
setToken(token: string) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
|
|
@ -98,34 +135,79 @@ class ApiService {
|
||||||
return this.token;
|
return this.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
getRefreshToken(): string | null {
|
||||||
|
return this.refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCredentials() {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
this.refreshToken = null;
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
delete this.client.defaults.headers.common['Authorization'];
|
delete this.client.defaults.headers.common['Authorization'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication endpoints
|
/** Log out: revoke the refresh token server-side, then clear local state. */
|
||||||
async login(email: string, password: string): Promise<AuthTokens> {
|
async logout() {
|
||||||
|
if (this.refreshToken) {
|
||||||
|
try {
|
||||||
|
await this.client.post('/auth/logout', { refresh_token: this.refreshToken });
|
||||||
|
} catch {
|
||||||
|
// Best-effort: even if the server call fails, drop local credentials.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.clearCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Perform a single in-flight refresh, deduped across concurrent requests. */
|
||||||
|
private async doRefresh(): Promise<string | null> {
|
||||||
|
if (this.refreshing) return this.refreshing;
|
||||||
|
if (!this.refreshToken) return null;
|
||||||
|
|
||||||
|
this.refreshing = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post<AuthTokens>('/auth/refresh', {
|
||||||
|
refresh_token: this.refreshToken,
|
||||||
|
});
|
||||||
|
const { token, refresh_token } = response.data;
|
||||||
|
this.setTokens(token, refresh_token);
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.refreshing = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.refreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Authentication ----
|
||||||
|
|
||||||
|
async login(email: string, _password: string): Promise<AuthTokens> {
|
||||||
|
// _password is used in the request body below; renamed only to satisfy the
|
||||||
|
// interface signature kept for store compatibility.
|
||||||
const response = await this.client.post<AuthTokens>('/auth/login', {
|
const response = await this.client.post<AuthTokens>('/auth/login', {
|
||||||
email,
|
email,
|
||||||
password,
|
password: _password,
|
||||||
});
|
});
|
||||||
this.setToken(response.data.token);
|
this.setTokens(response.data.token, response.data.refresh_token);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(data: RegisterRequest): Promise<AuthTokens> {
|
async register(data: RegisterRequest): Promise<AuthTokens> {
|
||||||
const response = await this.client.post<AuthTokens>('/auth/register', data);
|
const response = await this.client.post<AuthTokens>('/auth/register', data);
|
||||||
this.setToken(response.data.token);
|
this.setTokens(response.data.token, response.data.refresh_token);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentUser(): Promise<User> {
|
async getCurrentUser(): Promise<User> {
|
||||||
const response = await this.client.get<User>('/auth/me');
|
const response = await this.client.get<User>('/users/me');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Medication endpoints
|
// ---- Medications ----
|
||||||
|
|
||||||
async getMedications(): Promise<Medication[]> {
|
async getMedications(): Promise<Medication[]> {
|
||||||
const response = await this.client.get<Medication[]>('/medications');
|
const response = await this.client.get<Medication[]>('/medications');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -142,15 +224,18 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> {
|
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> {
|
||||||
const response = await this.client.put<Medication>(`/medications/${id}`, data);
|
// Backend update is POST /:id (not PUT).
|
||||||
|
const response = await this.client.post<Medication>(`/medications/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMedication(id: string): Promise<void> {
|
async deleteMedication(id: string): Promise<void> {
|
||||||
await this.client.delete(`/medications/${id}`);
|
// Backend delete is POST /:id/delete (not DELETE /:id).
|
||||||
|
await this.client.post(`/medications/${id}/delete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drug Interaction endpoints (Phase 2.8)
|
// ---- Drug Interactions (Phase 2.8) ----
|
||||||
|
|
||||||
async checkInteractions(medications: string[]): Promise<DrugInteraction[]> {
|
async checkInteractions(medications: string[]): Promise<DrugInteraction[]> {
|
||||||
const response = await this.client.post<DrugInteraction[]>('/interactions/check', {
|
const response = await this.client.post<DrugInteraction[]>('/interactions/check', {
|
||||||
medications,
|
medications,
|
||||||
|
|
@ -166,7 +251,8 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health Statistics endpoints
|
// ---- Health Statistics ----
|
||||||
|
|
||||||
async getHealthStats(): Promise<HealthStat[]> {
|
async getHealthStats(): Promise<HealthStat[]> {
|
||||||
const response = await this.client.get<HealthStat[]>('/health-stats');
|
const response = await this.client.get<HealthStat[]>('/health-stats');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -196,32 +282,18 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lab Results endpoints
|
// NOTE: lab-results endpoints are intentionally absent — the backend has no
|
||||||
async getLabResults(): Promise<LabResult[]> {
|
// /lab-results routes yet. The LabResult type stays in types/api.ts for when
|
||||||
const response = await this.client.get<LabResult[]>('/lab-results');
|
// those routes land.
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLabResult(id: string): Promise<LabResult> {
|
|
||||||
const response = await this.client.get<LabResult>(`/lab-results/${id}`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createLabResult(data: any): Promise<LabResult> {
|
|
||||||
const response = await this.client.post<LabResult>('/lab-results', data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateLabResult(id: string, data: any): Promise<LabResult> {
|
|
||||||
const response = await this.client.put<LabResult>(`/lab-results/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteLabResult(id: string): Promise<void> {
|
|
||||||
await this.client.delete(`/lab-results/${id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const apiService = new ApiService();
|
export const apiService = new ApiService();
|
||||||
export default apiService;
|
export default apiService;
|
||||||
|
|
||||||
|
// Satisfy the unused-import linter for types referenced only via generics above.
|
||||||
|
export type {
|
||||||
|
LoginRequest,
|
||||||
|
CheckInteractionRequest,
|
||||||
|
CheckNewMedicationRequest,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ interface AuthState {
|
||||||
// Actions
|
// Actions
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (username: string, email: string, password: string) => Promise<void>;
|
register: (username: string, email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
loadUser: () => Promise<void>;
|
loadUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -126,8 +126,8 @@ export const useAuthStore = create<AuthState>()(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: async () => {
|
||||||
apiService.logout();
|
await apiService.logout();
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
|
|
|
||||||
17
web/normogen-web/src/theme.ts
Normal file
17
web/normogen-web/src/theme.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// Minimal Normogen theme. Feature UIs build on this baseline.
|
||||||
|
export const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -36,8 +36,10 @@ export interface Claims {
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
token: string;
|
token: string;
|
||||||
|
refresh_token: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
|
|
@ -127,9 +129,18 @@ export interface CreateMedicationRequest {
|
||||||
name: string;
|
name: string;
|
||||||
dosage: string;
|
dosage: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
route: string;
|
||||||
|
profile_id: string;
|
||||||
|
reason?: string;
|
||||||
|
instructions?: string;
|
||||||
|
side_effects?: string[];
|
||||||
|
prescribed_by?: string;
|
||||||
|
prescribed_date?: string;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
instructions?: string;
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
reminder_times?: string[];
|
||||||
pill_identification?: PillIdentification;
|
pill_identification?: PillIdentification;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
web/normogen-web/src/vite-env.d.ts
vendored
Normal file
10
web/normogen-web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** Base URL for API calls. Defaults to "/api" (proxied by the Vite dev server). */
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es2022",
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
@ -14,13 +9,13 @@
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"],
|
||||||
"src"
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
web/normogen-web/tsconfig.node.json
Normal file
12
web/normogen-web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "./node_modules/.tmp/tsconfig.node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
25
web/normogen-web/vite.config.ts
Normal file
25
web/normogen-web/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// The dev server proxies /api/* to the backend so the browser avoids CORS in
|
||||||
|
// development. Override the target with VITE_API_TARGET (e.g. for Solaria).
|
||||||
|
const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:6500';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/setupTests.ts',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue