Yassine Mhirsi
commited on
Commit
·
b6bed80
1
Parent(s):
cbf89ee
Add routing and analysis features
Browse files- Introduced `react-router-dom` for navigation, enabling routing between Home and Analysis pages.
- Created `HomePage` for user registration and navigation.
- Developed `AnalysisPage` for CSV file upload and argument analysis.
- Implemented API services for user management and analysis.
- Updated constants for new API endpoints.
- Enforced explicit file extensions in imports for TypeScript files.
- Added utility functions for user management and CSV parsing.
- .cursor/rules/rules.mdc +26 -5
- package-lock.json +72 -0
- package.json +2 -0
- src/app/App.tsx +29 -7
- src/app/constants/index.ts +6 -1
- src/app/pages/AnalysisPage.tsx +229 -0
- src/app/pages/HomePage.tsx +115 -0
- src/app/services/analysis.service.ts +83 -0
- src/app/services/api-wrapper.ts +23 -5
- src/app/services/user.service.ts +76 -0
- src/app/types/analysis.types.ts +49 -0
- src/app/types/index.ts +4 -2
- src/app/types/user.types.ts +25 -0
- src/app/utils/index.ts +67 -0
- src/app/utils/user.utils.ts +51 -0
.cursor/rules/rules.mdc
CHANGED
|
@@ -54,12 +54,12 @@ const MyComponent: React.FC<MyComponentProps> = ({ title, count = 0 }) => {
|
|
| 54 |
- Always type API responses
|
| 55 |
|
| 56 |
```typescript
|
| 57 |
-
// Direct API call
|
| 58 |
-
import api from '../services/api-wrapper';
|
| 59 |
const data = await api.get<User[]>('/api/users');
|
| 60 |
|
| 61 |
-
// With hook
|
| 62 |
-
import { useApi } from '../hooks/useApi';
|
| 63 |
const { data, loading, error } = useApi<User[]>('/api/users');
|
| 64 |
```
|
| 65 |
|
|
@@ -75,6 +75,9 @@ export type Debate = {
|
|
| 75 |
topic: string;
|
| 76 |
arguments: Argument[];
|
| 77 |
};
|
|
|
|
|
|
|
|
|
|
| 78 |
```
|
| 79 |
|
| 80 |
### Constants
|
|
@@ -90,7 +93,13 @@ export type Debate = {
|
|
| 90 |
|
| 91 |
## Conventions
|
| 92 |
|
| 93 |
-
1. **Imports**:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
2. **Styling**: Tailwind-first, avoid custom CSS
|
| 95 |
3. **Error Handling**: Use ErrorBoundary for React errors, try/catch for API errors
|
| 96 |
4. **Loading States**: Use `Loading` component from `components/common/Loading`
|
|
@@ -105,15 +114,27 @@ export type Debate = {
|
|
| 105 |
- ❌ Don't create custom CSS files - use Tailwind
|
| 106 |
- ❌ Don't commit `.env*` files (except `.env.example`)
|
| 107 |
- ❌ Don't skip TypeScript types
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
## When Adding New Features
|
| 110 |
|
| 111 |
1. **New Page**: Create in `src/app/pages/` (`.tsx`), wire via `App.tsx`
|
|
|
|
| 112 |
2. **New Component**: Create in `src/app/components/` (`.tsx`), type props
|
|
|
|
| 113 |
3. **New API Endpoint**: Add to `API_ENDPOINTS` in constants, use `api-wrapper.ts`
|
|
|
|
| 114 |
4. **New Type**: Add to `src/app/types/`, export from `index.ts`
|
|
|
|
| 115 |
5. **New Hook**: Create in `src/app/hooks/` (`.ts`), type return values
|
|
|
|
| 116 |
6. **New Utility**: Add to `src/app/utils/index.ts`, keep pure and typed
|
|
|
|
|
|
|
| 117 |
|
| 118 |
## Quality Checks
|
| 119 |
|
|
|
|
| 54 |
- Always type API responses
|
| 55 |
|
| 56 |
```typescript
|
| 57 |
+
// Direct API call (note: include .ts extension)
|
| 58 |
+
import api from '../services/api-wrapper.ts';
|
| 59 |
const data = await api.get<User[]>('/api/users');
|
| 60 |
|
| 61 |
+
// With hook (note: include .ts extension)
|
| 62 |
+
import { useApi } from '../hooks/useApi.ts';
|
| 63 |
const { data, loading, error } = useApi<User[]>('/api/users');
|
| 64 |
```
|
| 65 |
|
|
|
|
| 75 |
topic: string;
|
| 76 |
arguments: Argument[];
|
| 77 |
};
|
| 78 |
+
|
| 79 |
+
// When importing types, use explicit extension:
|
| 80 |
+
import type { Debate } from '../types/debater.types.ts';
|
| 81 |
```
|
| 82 |
|
| 83 |
### Constants
|
|
|
|
| 93 |
|
| 94 |
## Conventions
|
| 95 |
|
| 96 |
+
1. **Imports**:
|
| 97 |
+
- Use relative paths from `src/`
|
| 98 |
+
- **ALWAYS include explicit file extensions** in imports:
|
| 99 |
+
- Use `.tsx` for React components (e.g., `import Component from './Component.tsx'`)
|
| 100 |
+
- Use `.ts` for TypeScript files (e.g., `import { func } from './utils/index.ts'`)
|
| 101 |
+
- Use `/index.ts` for directory barrel exports (e.g., `import { something } from './utils/index.ts'`)
|
| 102 |
+
- This is required for webpack module resolution in this project
|
| 103 |
2. **Styling**: Tailwind-first, avoid custom CSS
|
| 104 |
3. **Error Handling**: Use ErrorBoundary for React errors, try/catch for API errors
|
| 105 |
4. **Loading States**: Use `Loading` component from `components/common/Loading`
|
|
|
|
| 114 |
- ❌ Don't create custom CSS files - use Tailwind
|
| 115 |
- ❌ Don't commit `.env*` files (except `.env.example`)
|
| 116 |
- ❌ Don't skip TypeScript types
|
| 117 |
+
- ❌ **Don't omit file extensions in imports** - Always include `.ts` or `.tsx` extensions
|
| 118 |
+
- ❌ Bad: `import Component from './Component'`
|
| 119 |
+
- ✅ Good: `import Component from './Component.tsx'`
|
| 120 |
+
- ❌ Bad: `export * from './user.utils'`
|
| 121 |
+
- ✅ Good: `export * from './user.utils.ts'`
|
| 122 |
|
| 123 |
## When Adding New Features
|
| 124 |
|
| 125 |
1. **New Page**: Create in `src/app/pages/` (`.tsx`), wire via `App.tsx`
|
| 126 |
+
- Import with `.tsx` extension: `import NewPage from './pages/NewPage.tsx'`
|
| 127 |
2. **New Component**: Create in `src/app/components/` (`.tsx`), type props
|
| 128 |
+
- Import with `.tsx` extension: `import Component from './components/Component.tsx'`
|
| 129 |
3. **New API Endpoint**: Add to `API_ENDPOINTS` in constants, use `api-wrapper.ts`
|
| 130 |
+
- Import services with `.ts` extension: `import { service } from './services/service.ts'`
|
| 131 |
4. **New Type**: Add to `src/app/types/`, export from `index.ts`
|
| 132 |
+
- Import types with `.ts` extension: `import type { Type } from '../types/type.types.ts'`
|
| 133 |
5. **New Hook**: Create in `src/app/hooks/` (`.ts`), type return values
|
| 134 |
+
- Import hooks with `.ts` extension: `import { useHook } from '../hooks/useHook.ts'`
|
| 135 |
6. **New Utility**: Add to `src/app/utils/index.ts`, keep pure and typed
|
| 136 |
+
- Export with `.ts` extension: `export * from './new-util.ts'`
|
| 137 |
+
- Import from index: `import { util } from '../utils/index.ts'`
|
| 138 |
|
| 139 |
## Quality Checks
|
| 140 |
|
package-lock.json
CHANGED
|
@@ -12,8 +12,10 @@
|
|
| 12 |
"@testing-library/jest-dom": "^6.6.3",
|
| 13 |
"@testing-library/react": "^16.3.0",
|
| 14 |
"@testing-library/user-event": "^13.5.0",
|
|
|
|
| 15 |
"react": "^19.1.0",
|
| 16 |
"react-dom": "^19.1.0",
|
|
|
|
| 17 |
"react-scripts": "5.0.1",
|
| 18 |
"web-vitals": "^2.1.4"
|
| 19 |
},
|
|
@@ -13381,6 +13383,19 @@
|
|
| 13381 |
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
| 13382 |
"license": "MIT"
|
| 13383 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13384 |
"node_modules/prelude-ls": {
|
| 13385 |
"version": "1.2.1",
|
| 13386 |
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
|
@@ -13784,6 +13799,57 @@
|
|
| 13784 |
"node": ">=0.10.0"
|
| 13785 |
}
|
| 13786 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13787 |
"node_modules/react-scripts": {
|
| 13788 |
"version": "5.0.1",
|
| 13789 |
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
|
@@ -14767,6 +14833,12 @@
|
|
| 14767 |
"node": ">= 0.8"
|
| 14768 |
}
|
| 14769 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14770 |
"node_modules/set-function-length": {
|
| 14771 |
"version": "1.2.2",
|
| 14772 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
|
|
|
| 12 |
"@testing-library/jest-dom": "^6.6.3",
|
| 13 |
"@testing-library/react": "^16.3.0",
|
| 14 |
"@testing-library/user-event": "^13.5.0",
|
| 15 |
+
"postgres": "^3.4.7",
|
| 16 |
"react": "^19.1.0",
|
| 17 |
"react-dom": "^19.1.0",
|
| 18 |
+
"react-router-dom": "^7.10.1",
|
| 19 |
"react-scripts": "5.0.1",
|
| 20 |
"web-vitals": "^2.1.4"
|
| 21 |
},
|
|
|
|
| 13383 |
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
| 13384 |
"license": "MIT"
|
| 13385 |
},
|
| 13386 |
+
"node_modules/postgres": {
|
| 13387 |
+
"version": "3.4.7",
|
| 13388 |
+
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
| 13389 |
+
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
| 13390 |
+
"license": "Unlicense",
|
| 13391 |
+
"engines": {
|
| 13392 |
+
"node": ">=12"
|
| 13393 |
+
},
|
| 13394 |
+
"funding": {
|
| 13395 |
+
"type": "individual",
|
| 13396 |
+
"url": "https://github.com/sponsors/porsager"
|
| 13397 |
+
}
|
| 13398 |
+
},
|
| 13399 |
"node_modules/prelude-ls": {
|
| 13400 |
"version": "1.2.1",
|
| 13401 |
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
|
|
|
| 13799 |
"node": ">=0.10.0"
|
| 13800 |
}
|
| 13801 |
},
|
| 13802 |
+
"node_modules/react-router": {
|
| 13803 |
+
"version": "7.10.1",
|
| 13804 |
+
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
| 13805 |
+
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
| 13806 |
+
"license": "MIT",
|
| 13807 |
+
"dependencies": {
|
| 13808 |
+
"cookie": "^1.0.1",
|
| 13809 |
+
"set-cookie-parser": "^2.6.0"
|
| 13810 |
+
},
|
| 13811 |
+
"engines": {
|
| 13812 |
+
"node": ">=20.0.0"
|
| 13813 |
+
},
|
| 13814 |
+
"peerDependencies": {
|
| 13815 |
+
"react": ">=18",
|
| 13816 |
+
"react-dom": ">=18"
|
| 13817 |
+
},
|
| 13818 |
+
"peerDependenciesMeta": {
|
| 13819 |
+
"react-dom": {
|
| 13820 |
+
"optional": true
|
| 13821 |
+
}
|
| 13822 |
+
}
|
| 13823 |
+
},
|
| 13824 |
+
"node_modules/react-router-dom": {
|
| 13825 |
+
"version": "7.10.1",
|
| 13826 |
+
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
| 13827 |
+
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
| 13828 |
+
"license": "MIT",
|
| 13829 |
+
"dependencies": {
|
| 13830 |
+
"react-router": "7.10.1"
|
| 13831 |
+
},
|
| 13832 |
+
"engines": {
|
| 13833 |
+
"node": ">=20.0.0"
|
| 13834 |
+
},
|
| 13835 |
+
"peerDependencies": {
|
| 13836 |
+
"react": ">=18",
|
| 13837 |
+
"react-dom": ">=18"
|
| 13838 |
+
}
|
| 13839 |
+
},
|
| 13840 |
+
"node_modules/react-router/node_modules/cookie": {
|
| 13841 |
+
"version": "1.1.1",
|
| 13842 |
+
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
| 13843 |
+
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
| 13844 |
+
"license": "MIT",
|
| 13845 |
+
"engines": {
|
| 13846 |
+
"node": ">=18"
|
| 13847 |
+
},
|
| 13848 |
+
"funding": {
|
| 13849 |
+
"type": "opencollective",
|
| 13850 |
+
"url": "https://opencollective.com/express"
|
| 13851 |
+
}
|
| 13852 |
+
},
|
| 13853 |
"node_modules/react-scripts": {
|
| 13854 |
"version": "5.0.1",
|
| 13855 |
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
|
|
|
| 14833 |
"node": ">= 0.8"
|
| 14834 |
}
|
| 14835 |
},
|
| 14836 |
+
"node_modules/set-cookie-parser": {
|
| 14837 |
+
"version": "2.7.2",
|
| 14838 |
+
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
| 14839 |
+
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
| 14840 |
+
"license": "MIT"
|
| 14841 |
+
},
|
| 14842 |
"node_modules/set-function-length": {
|
| 14843 |
"version": "1.2.2",
|
| 14844 |
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
package.json
CHANGED
|
@@ -7,8 +7,10 @@
|
|
| 7 |
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
"@testing-library/react": "^16.3.0",
|
| 9 |
"@testing-library/user-event": "^13.5.0",
|
|
|
|
| 10 |
"react": "^19.1.0",
|
| 11 |
"react-dom": "^19.1.0",
|
|
|
|
| 12 |
"react-scripts": "5.0.1",
|
| 13 |
"web-vitals": "^2.1.4"
|
| 14 |
},
|
|
|
|
| 7 |
"@testing-library/jest-dom": "^6.6.3",
|
| 8 |
"@testing-library/react": "^16.3.0",
|
| 9 |
"@testing-library/user-event": "^13.5.0",
|
| 10 |
+
"postgres": "^3.4.7",
|
| 11 |
"react": "^19.1.0",
|
| 12 |
"react-dom": "^19.1.0",
|
| 13 |
+
"react-router-dom": "^7.10.1",
|
| 14 |
"react-scripts": "5.0.1",
|
| 15 |
"web-vitals": "^2.1.4"
|
| 16 |
},
|
src/app/App.tsx
CHANGED
|
@@ -1,13 +1,35 @@
|
|
| 1 |
import React from 'react';
|
|
|
|
| 2 |
import MainLayout from './layouts/MainLayout.tsx';
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
const App: React.FC = () =>
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export default App;
|
| 13 |
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
| 3 |
import MainLayout from './layouts/MainLayout.tsx';
|
| 4 |
+
import HomePage from './pages/HomePage.tsx';
|
| 5 |
+
import AnalysisPage from './pages/AnalysisPage.tsx';
|
| 6 |
+
import { isUserRegistered } from './utils/index.ts';
|
| 7 |
|
| 8 |
+
const App: React.FC = () => {
|
| 9 |
+
// Check if user is already registered
|
| 10 |
+
const userRegistered = isUserRegistered();
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<BrowserRouter>
|
| 14 |
+
<MainLayout>
|
| 15 |
+
<Routes>
|
| 16 |
+
<Route
|
| 17 |
+
path="/"
|
| 18 |
+
element={
|
| 19 |
+
userRegistered ? (
|
| 20 |
+
<Navigate to="/analysis" replace />
|
| 21 |
+
) : (
|
| 22 |
+
<HomePage />
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
/>
|
| 26 |
+
<Route path="/analysis" element={<AnalysisPage />} />
|
| 27 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 28 |
+
</Routes>
|
| 29 |
+
</MainLayout>
|
| 30 |
+
</BrowserRouter>
|
| 31 |
+
);
|
| 32 |
+
};
|
| 33 |
|
| 34 |
export default App;
|
| 35 |
|
src/app/constants/index.ts
CHANGED
|
@@ -4,7 +4,12 @@
|
|
| 4 |
|
| 5 |
// API Endpoints (add your endpoints here as you build)
|
| 6 |
export const API_ENDPOINTS = {
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
} as const;
|
| 9 |
|
| 10 |
// App configuration
|
|
|
|
| 4 |
|
| 5 |
// API Endpoints (add your endpoints here as you build)
|
| 6 |
export const API_ENDPOINTS = {
|
| 7 |
+
USER_REGISTER: '/api/v1/user/register',
|
| 8 |
+
USER_ME: '/api/v1/user/me',
|
| 9 |
+
USER_BY_UNIQUE_ID: '/api/v1/user/by-unique-id',
|
| 10 |
+
USER_UPDATE_NAME: '/api/v1/user/me/name',
|
| 11 |
+
ANALYSIS: '/api/v1/analyse',
|
| 12 |
+
ANALYSIS_CSV: '/api/v1/analyse/csv',
|
| 13 |
} as const;
|
| 14 |
|
| 15 |
// App configuration
|
src/app/pages/AnalysisPage.tsx
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useMemo, useState } from 'react';
|
| 2 |
+
import { parseCsv, formatError } from '../utils/index.ts';
|
| 3 |
+
import { CsvRow } from '../types/index.ts';
|
| 4 |
+
import { AnalysisResult } from '../types/analysis.types.ts';
|
| 5 |
+
import { analyzeArgumentsFromCsv } from '../services/analysis.service.ts';
|
| 6 |
+
|
| 7 |
+
const AnalysisPage: React.FC = () => {
|
| 8 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 9 |
+
const [headers, setHeaders] = useState<string[]>(['id', 'argument']);
|
| 10 |
+
const [rows, setRows] = useState<CsvRow[]>([]);
|
| 11 |
+
const [error, setError] = useState<string | null>(null);
|
| 12 |
+
const [isAnalyzing, setIsAnalyzing] = useState<boolean>(false);
|
| 13 |
+
const [analysisResults, setAnalysisResults] = useState<AnalysisResult[]>([]);
|
| 14 |
+
|
| 15 |
+
const fileName = useMemo(
|
| 16 |
+
() => selectedFile?.name ?? 'Select a CSV file with arguments',
|
| 17 |
+
[selectedFile]
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 21 |
+
const file = event.target.files?.[0] ?? null;
|
| 22 |
+
setSelectedFile(file);
|
| 23 |
+
setError(null);
|
| 24 |
+
setAnalysisResults([]);
|
| 25 |
+
|
| 26 |
+
if (!file) {
|
| 27 |
+
setHeaders(['id', 'argument']);
|
| 28 |
+
setRows([]);
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Automatically parse and preview CSV
|
| 33 |
+
try {
|
| 34 |
+
const content = await file.text();
|
| 35 |
+
const parsed = parseCsv(content);
|
| 36 |
+
|
| 37 |
+
setHeaders(parsed.headers.length > 0 ? parsed.headers : ['id', 'argument']);
|
| 38 |
+
setRows(parsed.rows);
|
| 39 |
+
|
| 40 |
+
if (parsed.rows.length === 0) {
|
| 41 |
+
setError('No data found in the CSV file.');
|
| 42 |
+
}
|
| 43 |
+
} catch (err) {
|
| 44 |
+
setError(
|
| 45 |
+
err instanceof Error ? err.message : 'Unable to read the CSV file.'
|
| 46 |
+
);
|
| 47 |
+
setHeaders(['id', 'argument']);
|
| 48 |
+
setRows([]);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleAnalyze = async () => {
|
| 53 |
+
if (!selectedFile) {
|
| 54 |
+
setError('Please choose a CSV file to analyze.');
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
setIsAnalyzing(true);
|
| 59 |
+
setError(null);
|
| 60 |
+
setAnalysisResults([]);
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
const response = await analyzeArgumentsFromCsv(selectedFile);
|
| 64 |
+
setAnalysisResults(response.results);
|
| 65 |
+
} catch (err) {
|
| 66 |
+
setError(formatError(err));
|
| 67 |
+
} finally {
|
| 68 |
+
setIsAnalyzing(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="min-h-screen bg-slate-50 px-4 py-10">
|
| 74 |
+
<div className="mx-auto max-w-4xl rounded-lg border border-slate-200 bg-white shadow-sm">
|
| 75 |
+
<div className="border-b border-slate-200 px-6 py-4">
|
| 76 |
+
<h1 className="text-lg font-semibold text-slate-800">
|
| 77 |
+
Analysis page (open static)
|
| 78 |
+
</h1>
|
| 79 |
+
<p className="text-sm text-slate-500">
|
| 80 |
+
Upload a CSV file containing arguments to preview its contents.
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div className="space-y-6 p-6">
|
| 85 |
+
<div className="flex flex-col gap-3">
|
| 86 |
+
<label className="text-sm font-medium text-slate-700">
|
| 87 |
+
Upload a CSV file
|
| 88 |
+
</label>
|
| 89 |
+
|
| 90 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 91 |
+
<label className="flex w-full max-w-xl cursor-pointer items-center justify-between rounded-full border border-slate-300 bg-slate-50 px-5 py-3 transition hover:border-slate-400">
|
| 92 |
+
<span className="truncate text-sm font-semibold text-slate-500">
|
| 93 |
+
{fileName}
|
| 94 |
+
</span>
|
| 95 |
+
<input
|
| 96 |
+
type="file"
|
| 97 |
+
accept=".csv,text/csv"
|
| 98 |
+
className="hidden"
|
| 99 |
+
onChange={handleFileChange}
|
| 100 |
+
/>
|
| 101 |
+
</label>
|
| 102 |
+
|
| 103 |
+
<button
|
| 104 |
+
type="button"
|
| 105 |
+
onClick={handleAnalyze}
|
| 106 |
+
disabled={!selectedFile || rows.length === 0 || isAnalyzing}
|
| 107 |
+
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
| 108 |
+
>
|
| 109 |
+
{isAnalyzing ? 'Analyzing...' : 'Analyze'}
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{rows.length > 0 && (
|
| 117 |
+
<div className="overflow-hidden rounded-lg border border-slate-200">
|
| 118 |
+
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200">
|
| 119 |
+
<h2 className="text-sm font-semibold text-slate-700">
|
| 120 |
+
CSV Preview ({rows.length} rows)
|
| 121 |
+
</h2>
|
| 122 |
+
</div>
|
| 123 |
+
<table className="min-w-full divide-y divide-slate-200">
|
| 124 |
+
<thead className="bg-slate-50">
|
| 125 |
+
<tr>
|
| 126 |
+
{headers.map((header) => (
|
| 127 |
+
<th
|
| 128 |
+
key={header}
|
| 129 |
+
scope="col"
|
| 130 |
+
className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600"
|
| 131 |
+
>
|
| 132 |
+
{header}
|
| 133 |
+
</th>
|
| 134 |
+
))}
|
| 135 |
+
</tr>
|
| 136 |
+
</thead>
|
| 137 |
+
<tbody className="divide-y divide-slate-200 bg-white">
|
| 138 |
+
{rows.map((row, index) => (
|
| 139 |
+
<tr key={row.id ?? `row-${index}`} className="hover:bg-slate-50">
|
| 140 |
+
{headers.map((header) => (
|
| 141 |
+
<td
|
| 142 |
+
key={`${header}-${index}`}
|
| 143 |
+
className="whitespace-pre-wrap px-4 py-3 text-sm text-slate-700"
|
| 144 |
+
>
|
| 145 |
+
{row[header] ?? ''}
|
| 146 |
+
</td>
|
| 147 |
+
))}
|
| 148 |
+
</tr>
|
| 149 |
+
))}
|
| 150 |
+
</tbody>
|
| 151 |
+
</table>
|
| 152 |
+
</div>
|
| 153 |
+
)}
|
| 154 |
+
|
| 155 |
+
{analysisResults.length > 0 && (
|
| 156 |
+
<div className="overflow-hidden rounded-lg border border-slate-200">
|
| 157 |
+
<div className="bg-green-50 px-4 py-2 border-b border-slate-200">
|
| 158 |
+
<h2 className="text-sm font-semibold text-green-800">
|
| 159 |
+
Analysis Results ({analysisResults.length} results)
|
| 160 |
+
</h2>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="overflow-x-auto">
|
| 163 |
+
<table className="min-w-full divide-y divide-slate-200">
|
| 164 |
+
<thead className="bg-slate-50">
|
| 165 |
+
<tr>
|
| 166 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 167 |
+
Argument
|
| 168 |
+
</th>
|
| 169 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 170 |
+
Topic
|
| 171 |
+
</th>
|
| 172 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 173 |
+
Stance
|
| 174 |
+
</th>
|
| 175 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 176 |
+
Confidence
|
| 177 |
+
</th>
|
| 178 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 179 |
+
PRO
|
| 180 |
+
</th>
|
| 181 |
+
<th className="px-4 py-3 text-left text-sm font-semibold uppercase tracking-wide text-slate-600">
|
| 182 |
+
CON
|
| 183 |
+
</th>
|
| 184 |
+
</tr>
|
| 185 |
+
</thead>
|
| 186 |
+
<tbody className="divide-y divide-slate-200 bg-white">
|
| 187 |
+
{analysisResults.map((result) => (
|
| 188 |
+
<tr key={result.id} className="hover:bg-slate-50">
|
| 189 |
+
<td className="whitespace-pre-wrap px-4 py-3 text-sm text-slate-700 max-w-md">
|
| 190 |
+
{result.argument}
|
| 191 |
+
</td>
|
| 192 |
+
<td className="px-4 py-3 text-sm text-slate-700">
|
| 193 |
+
{result.topic}
|
| 194 |
+
</td>
|
| 195 |
+
<td className="px-4 py-3 text-sm">
|
| 196 |
+
<span
|
| 197 |
+
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
| 198 |
+
result.predicted_stance === 'PRO'
|
| 199 |
+
? 'bg-green-100 text-green-800'
|
| 200 |
+
: 'bg-red-100 text-red-800'
|
| 201 |
+
}`}
|
| 202 |
+
>
|
| 203 |
+
{result.predicted_stance}
|
| 204 |
+
</span>
|
| 205 |
+
</td>
|
| 206 |
+
<td className="px-4 py-3 text-sm text-slate-700">
|
| 207 |
+
{(result.confidence * 100).toFixed(1)}%
|
| 208 |
+
</td>
|
| 209 |
+
<td className="px-4 py-3 text-sm text-slate-700">
|
| 210 |
+
{(result.probability_pro * 100).toFixed(1)}%
|
| 211 |
+
</td>
|
| 212 |
+
<td className="px-4 py-3 text-sm text-slate-700">
|
| 213 |
+
{(result.probability_con * 100).toFixed(1)}%
|
| 214 |
+
</td>
|
| 215 |
+
</tr>
|
| 216 |
+
))}
|
| 217 |
+
</tbody>
|
| 218 |
+
</table>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
export default AnalysisPage;
|
| 229 |
+
|
src/app/pages/HomePage.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { registerUser } from '../services/user.service.ts';
|
| 4 |
+
import { getOrCreateUniqueId, setUserId, formatError } from '../utils/index.ts';
|
| 5 |
+
import Loading from '../components/common/Loading.tsx';
|
| 6 |
+
|
| 7 |
+
const HomePage: React.FC = () => {
|
| 8 |
+
const [name, setName] = useState<string>('');
|
| 9 |
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
| 10 |
+
const [error, setError] = useState<string | null>(null);
|
| 11 |
+
const navigate = useNavigate();
|
| 12 |
+
|
| 13 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 14 |
+
e.preventDefault();
|
| 15 |
+
await registerAndNavigate(name.trim() || null);
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const handleSkip = async () => {
|
| 19 |
+
await registerAndNavigate(null);
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const registerAndNavigate = async (userName: string | null) => {
|
| 23 |
+
setIsLoading(true);
|
| 24 |
+
setError(null);
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const uniqueId = getOrCreateUniqueId();
|
| 28 |
+
const user = await registerUser({
|
| 29 |
+
unique_id: uniqueId,
|
| 30 |
+
name: userName,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
// Store user_id for future API calls
|
| 34 |
+
setUserId(user.id);
|
| 35 |
+
|
| 36 |
+
// Navigate to analysis page
|
| 37 |
+
navigate('/analysis');
|
| 38 |
+
} catch (err) {
|
| 39 |
+
const errorMessage = formatError(err);
|
| 40 |
+
setError(errorMessage);
|
| 41 |
+
setIsLoading(false);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
if (isLoading) {
|
| 46 |
+
return (
|
| 47 |
+
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
| 48 |
+
<Loading />
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-10">
|
| 55 |
+
<div className="w-full max-w-md rounded-lg border border-slate-200 bg-white shadow-sm">
|
| 56 |
+
<div className="border-b border-slate-200 px-6 py-6">
|
| 57 |
+
<h1 className="text-2xl font-bold text-slate-800">
|
| 58 |
+
Welcome to NLP IBM Debater
|
| 59 |
+
</h1>
|
| 60 |
+
<p className="mt-2 text-sm text-slate-500">
|
| 61 |
+
Enter your name to get started, or skip to use a random name.
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<form onSubmit={handleSubmit} className="space-y-6 p-6">
|
| 66 |
+
<div>
|
| 67 |
+
<label
|
| 68 |
+
htmlFor="name"
|
| 69 |
+
className="block text-sm font-medium text-slate-700"
|
| 70 |
+
>
|
| 71 |
+
Your Name (Optional)
|
| 72 |
+
</label>
|
| 73 |
+
<input
|
| 74 |
+
id="name"
|
| 75 |
+
type="text"
|
| 76 |
+
value={name}
|
| 77 |
+
onChange={(e) => setName(e.target.value)}
|
| 78 |
+
placeholder="Enter your name"
|
| 79 |
+
maxLength={100}
|
| 80 |
+
className="mt-2 w-full rounded-md border border-slate-300 px-4 py-2 text-sm text-slate-700 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20"
|
| 81 |
+
disabled={isLoading}
|
| 82 |
+
/>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{error && (
|
| 86 |
+
<div className="rounded-md bg-red-50 p-3">
|
| 87 |
+
<p className="text-sm text-red-600">{error}</p>
|
| 88 |
+
</div>
|
| 89 |
+
)}
|
| 90 |
+
|
| 91 |
+
<div className="flex flex-col gap-3 sm:flex-row">
|
| 92 |
+
<button
|
| 93 |
+
type="submit"
|
| 94 |
+
disabled={isLoading}
|
| 95 |
+
className="flex-1 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
| 96 |
+
>
|
| 97 |
+
Continue
|
| 98 |
+
</button>
|
| 99 |
+
<button
|
| 100 |
+
type="button"
|
| 101 |
+
onClick={handleSkip}
|
| 102 |
+
disabled={isLoading}
|
| 103 |
+
className="flex-1 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
| 104 |
+
>
|
| 105 |
+
Skip (Random Name)
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
| 108 |
+
</form>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
export default HomePage;
|
| 115 |
+
|
src/app/services/analysis.service.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Analysis service for API calls related to argument analysis
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import api from './api-wrapper.ts';
|
| 6 |
+
import type {
|
| 7 |
+
AnalysisRequest,
|
| 8 |
+
AnalysisResponse,
|
| 9 |
+
GetAnalysisResponse,
|
| 10 |
+
} from '../types/analysis.types.ts';
|
| 11 |
+
import { API_ENDPOINTS } from '../constants/index.ts';
|
| 12 |
+
import { getUserId } from '../utils/index.ts';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Analyze arguments from JSON body
|
| 16 |
+
*/
|
| 17 |
+
export async function analyzeArguments(
|
| 18 |
+
request: AnalysisRequest
|
| 19 |
+
): Promise<AnalysisResponse> {
|
| 20 |
+
return api.post<AnalysisResponse, AnalysisRequest>(
|
| 21 |
+
API_ENDPOINTS.ANALYSIS,
|
| 22 |
+
request
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Analyze arguments from CSV file upload
|
| 28 |
+
*/
|
| 29 |
+
export async function analyzeArgumentsFromCsv(
|
| 30 |
+
file: File
|
| 31 |
+
): Promise<AnalysisResponse> {
|
| 32 |
+
const formData = new FormData();
|
| 33 |
+
formData.append('file', file);
|
| 34 |
+
|
| 35 |
+
const BASE_URL =
|
| 36 |
+
process.env.REACT_APP_API_BASE_URL?.replace(/\/$/, '') || '';
|
| 37 |
+
const url = `${BASE_URL}${API_ENDPOINTS.ANALYSIS_CSV}`;
|
| 38 |
+
|
| 39 |
+
// Get user ID for header
|
| 40 |
+
const userId = getUserId();
|
| 41 |
+
const headers: Record<string, string> = {};
|
| 42 |
+
if (userId) {
|
| 43 |
+
headers['X-User-ID'] = userId;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const response = await fetch(url, {
|
| 47 |
+
method: 'POST',
|
| 48 |
+
headers,
|
| 49 |
+
body: formData,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
const contentType = response.headers.get('content-type');
|
| 53 |
+
const isJson = contentType?.includes('application/json');
|
| 54 |
+
const payload = isJson ? await response.json().catch(() => undefined) : undefined;
|
| 55 |
+
|
| 56 |
+
if (!response.ok) {
|
| 57 |
+
const error = {
|
| 58 |
+
status: response.status,
|
| 59 |
+
message:
|
| 60 |
+
(payload as { detail?: string })?.detail ||
|
| 61 |
+
(payload as { message?: string })?.message ||
|
| 62 |
+
response.statusText ||
|
| 63 |
+
'Request failed',
|
| 64 |
+
details: payload,
|
| 65 |
+
};
|
| 66 |
+
throw error;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return payload as AnalysisResponse;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Get user's analysis results
|
| 74 |
+
*/
|
| 75 |
+
export async function getAnalysisResults(
|
| 76 |
+
limit: number = 100,
|
| 77 |
+
offset: number = 0
|
| 78 |
+
): Promise<GetAnalysisResponse> {
|
| 79 |
+
return api.get<GetAnalysisResponse>(
|
| 80 |
+
`${API_ENDPOINTS.ANALYSIS}?limit=${limit}&offset=${offset}`
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
src/app/services/api-wrapper.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import type { ApiRequestOptions, ApiError } from '../types/api.types';
|
|
|
|
| 2 |
|
| 3 |
const DEFAULT_HEADERS: HeadersInit = {
|
| 4 |
'Content-Type': 'application/json',
|
|
@@ -18,12 +19,29 @@ export async function apiRequest<TResponse = unknown, TBody = unknown>({
|
|
| 18 |
}: ApiRequestOptions<TBody>): Promise<TResponse> {
|
| 19 |
const url = `${BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const response = await fetch(url, {
|
| 22 |
method,
|
| 23 |
-
headers:
|
| 24 |
-
...DEFAULT_HEADERS,
|
| 25 |
-
...(headers ?? {}),
|
| 26 |
-
},
|
| 27 |
body: body ? JSON.stringify(body) : undefined,
|
| 28 |
signal,
|
| 29 |
});
|
|
|
|
| 1 |
+
import type { ApiRequestOptions, ApiError } from '../types/api.types.ts';
|
| 2 |
+
import { getUserId } from '../utils/index.ts';
|
| 3 |
|
| 4 |
const DEFAULT_HEADERS: HeadersInit = {
|
| 5 |
'Content-Type': 'application/json',
|
|
|
|
| 19 |
}: ApiRequestOptions<TBody>): Promise<TResponse> {
|
| 20 |
const url = `${BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`;
|
| 21 |
|
| 22 |
+
// Automatically include user ID header if available
|
| 23 |
+
const userId = getUserId();
|
| 24 |
+
|
| 25 |
+
// Convert headers to plain object if needed
|
| 26 |
+
let baseHeaders: Record<string, string> = {};
|
| 27 |
+
if (headers instanceof Headers) {
|
| 28 |
+
baseHeaders = Object.fromEntries(headers.entries());
|
| 29 |
+
} else if (headers) {
|
| 30 |
+
baseHeaders = headers as Record<string, string>;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const requestHeaders: Record<string, string> = {
|
| 34 |
+
...(DEFAULT_HEADERS as Record<string, string>),
|
| 35 |
+
...baseHeaders,
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
if (userId) {
|
| 39 |
+
requestHeaders['X-User-ID'] = userId;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
const response = await fetch(url, {
|
| 43 |
method,
|
| 44 |
+
headers: requestHeaders,
|
|
|
|
|
|
|
|
|
|
| 45 |
body: body ? JSON.stringify(body) : undefined,
|
| 46 |
signal,
|
| 47 |
});
|
src/app/services/user.service.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* User service for API calls related to user management
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import api from './api-wrapper.ts';
|
| 6 |
+
import type {
|
| 7 |
+
UserRegisterRequest,
|
| 8 |
+
UserRegisterResponse,
|
| 9 |
+
UserUpdateNameRequest,
|
| 10 |
+
UserUpdateNameResponse,
|
| 11 |
+
User,
|
| 12 |
+
} from '../types/user.types.ts';
|
| 13 |
+
import { API_ENDPOINTS } from '../constants/index.ts';
|
| 14 |
+
import { getUserId } from '../utils/index.ts';
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Register a new user or get existing user by unique_id
|
| 18 |
+
*/
|
| 19 |
+
export async function registerUser(
|
| 20 |
+
request: UserRegisterRequest
|
| 21 |
+
): Promise<UserRegisterResponse> {
|
| 22 |
+
return api.post<UserRegisterResponse, UserRegisterRequest>(
|
| 23 |
+
API_ENDPOINTS.USER_REGISTER,
|
| 24 |
+
request
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Get current user by user_id (requires X-User-ID header)
|
| 30 |
+
*/
|
| 31 |
+
export async function getCurrentUser(): Promise<User> {
|
| 32 |
+
const userId = getUserId();
|
| 33 |
+
if (!userId) {
|
| 34 |
+
throw new Error('User ID not found in localStorage');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return api.get<User>(API_ENDPOINTS.USER_ME, {
|
| 38 |
+
headers: {
|
| 39 |
+
'X-User-ID': userId,
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Get user by unique_id
|
| 46 |
+
*/
|
| 47 |
+
export async function getUserByUniqueId(
|
| 48 |
+
uniqueId: string
|
| 49 |
+
): Promise<User> {
|
| 50 |
+
return api.get<User>(
|
| 51 |
+
`${API_ENDPOINTS.USER_BY_UNIQUE_ID}?unique_id=${encodeURIComponent(uniqueId)}`
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Update user's display name
|
| 57 |
+
*/
|
| 58 |
+
export async function updateUserName(
|
| 59 |
+
request: UserUpdateNameRequest
|
| 60 |
+
): Promise<UserUpdateNameResponse> {
|
| 61 |
+
const userId = getUserId();
|
| 62 |
+
if (!userId) {
|
| 63 |
+
throw new Error('User ID not found in localStorage');
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return api.patch<UserUpdateNameResponse, UserUpdateNameRequest>(
|
| 67 |
+
API_ENDPOINTS.USER_UPDATE_NAME,
|
| 68 |
+
request,
|
| 69 |
+
{
|
| 70 |
+
headers: {
|
| 71 |
+
'X-User-ID': userId,
|
| 72 |
+
},
|
| 73 |
+
}
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
src/app/types/analysis.types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type CsvRow = Record<string, string>;
|
| 2 |
+
|
| 3 |
+
export type CsvParseResult = {
|
| 4 |
+
headers: string[];
|
| 5 |
+
rows: CsvRow[];
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Analysis result for a single argument
|
| 10 |
+
*/
|
| 11 |
+
export type AnalysisResult = {
|
| 12 |
+
id: string;
|
| 13 |
+
user_id: string;
|
| 14 |
+
argument: string;
|
| 15 |
+
topic: string;
|
| 16 |
+
predicted_stance: 'PRO' | 'CON';
|
| 17 |
+
confidence: number;
|
| 18 |
+
probability_con: number;
|
| 19 |
+
probability_pro: number;
|
| 20 |
+
created_at: string;
|
| 21 |
+
updated_at: string;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Response from analysis endpoint
|
| 26 |
+
*/
|
| 27 |
+
export type AnalysisResponse = {
|
| 28 |
+
results: AnalysisResult[];
|
| 29 |
+
total_processed: number;
|
| 30 |
+
timestamp: string;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Request for analysis with JSON body
|
| 35 |
+
*/
|
| 36 |
+
export type AnalysisRequest = {
|
| 37 |
+
arguments: string[];
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Response from get analysis results endpoint
|
| 42 |
+
*/
|
| 43 |
+
export type GetAnalysisResponse = {
|
| 44 |
+
results: AnalysisResult[];
|
| 45 |
+
total: number;
|
| 46 |
+
limit: number;
|
| 47 |
+
offset: number;
|
| 48 |
+
};
|
| 49 |
+
|
src/app/types/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
/**
|
| 2 |
* Central export point for all types
|
| 3 |
-
* Import types from here: import { SomeType } from '../types';
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
export * from './api.types';
|
|
|
|
|
|
|
| 7 |
|
|
|
|
| 1 |
/**
|
| 2 |
* Central export point for all types
|
| 3 |
+
* Import types from here: import { SomeType } from '../types/index.ts';
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
export * from './api.types.ts';
|
| 7 |
+
export * from './analysis.types.ts';
|
| 8 |
+
export * from './user.types.ts';
|
| 9 |
|
src/app/types/user.types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* User-related TypeScript types
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export type User = {
|
| 6 |
+
id: string;
|
| 7 |
+
unique_id: string;
|
| 8 |
+
name: string;
|
| 9 |
+
created_at: string;
|
| 10 |
+
updated_at: string;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export type UserRegisterRequest = {
|
| 14 |
+
unique_id: string;
|
| 15 |
+
name?: string | null;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type UserRegisterResponse = User;
|
| 19 |
+
|
| 20 |
+
export type UserUpdateNameRequest = {
|
| 21 |
+
name: string;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export type UserUpdateNameResponse = User;
|
| 25 |
+
|
src/app/utils/index.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
| 2 |
* Utility functions and helpers
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
/**
|
| 6 |
* Debounce function - delays execution until after wait time
|
| 7 |
*/
|
|
@@ -49,3 +54,65 @@ export function isEmpty(value: unknown): boolean {
|
|
| 49 |
return false;
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
* Utility functions and helpers
|
| 3 |
*/
|
| 4 |
|
| 5 |
+
import { CsvParseResult, CsvRow } from '../types/index.ts';
|
| 6 |
+
|
| 7 |
+
// Export user utilities
|
| 8 |
+
export * from './user.utils.ts';
|
| 9 |
+
|
| 10 |
/**
|
| 11 |
* Debounce function - delays execution until after wait time
|
| 12 |
*/
|
|
|
|
| 54 |
return false;
|
| 55 |
}
|
| 56 |
|
| 57 |
+
/**
|
| 58 |
+
* Parse a CSV string into headers and rows.
|
| 59 |
+
* Supports quoted fields and trims whitespace around values.
|
| 60 |
+
*/
|
| 61 |
+
export function parseCsv(content: string): CsvParseResult {
|
| 62 |
+
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
| 63 |
+
|
| 64 |
+
if (lines.length === 0) {
|
| 65 |
+
return { headers: [], rows: [] };
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const parseLine = (line: string): string[] => {
|
| 69 |
+
const cells: string[] = [];
|
| 70 |
+
let current = '';
|
| 71 |
+
let inQuotes = false;
|
| 72 |
+
|
| 73 |
+
for (let i = 0; i < line.length; i += 1) {
|
| 74 |
+
const char = line[i];
|
| 75 |
+
const nextChar = line[i + 1];
|
| 76 |
+
|
| 77 |
+
if (char === '"') {
|
| 78 |
+
if (inQuotes && nextChar === '"') {
|
| 79 |
+
current += '"';
|
| 80 |
+
i += 1;
|
| 81 |
+
} else {
|
| 82 |
+
inQuotes = !inQuotes;
|
| 83 |
+
}
|
| 84 |
+
} else if (char === ',' && !inQuotes) {
|
| 85 |
+
cells.push(current.trim());
|
| 86 |
+
current = '';
|
| 87 |
+
} else {
|
| 88 |
+
current += char;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
cells.push(current.trim());
|
| 93 |
+
return cells;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const headers = parseLine(lines[0]).map((header, index) =>
|
| 97 |
+
header ? header : `column_${index + 1}`
|
| 98 |
+
);
|
| 99 |
+
|
| 100 |
+
const rows: CsvRow[] = lines.slice(1).map((line) => {
|
| 101 |
+
const values = parseLine(line);
|
| 102 |
+
const row: CsvRow = {};
|
| 103 |
+
|
| 104 |
+
headers.forEach((header, idx) => {
|
| 105 |
+
row[header] = values[idx]?.trim() ?? '';
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
return row;
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
headers,
|
| 113 |
+
rows: rows.filter((row) =>
|
| 114 |
+
Object.values(row).some((value) => value !== undefined && value !== '')
|
| 115 |
+
),
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
src/app/utils/user.utils.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* User-related utility functions
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
const UNIQUE_ID_KEY = 'user_unique_id';
|
| 6 |
+
const USER_ID_KEY = 'user_id';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Get or create a unique browser identifier
|
| 10 |
+
*/
|
| 11 |
+
export function getOrCreateUniqueId(): string {
|
| 12 |
+
let uniqueId = localStorage.getItem(UNIQUE_ID_KEY);
|
| 13 |
+
|
| 14 |
+
if (!uniqueId) {
|
| 15 |
+
// Generate a UUID v4
|
| 16 |
+
uniqueId = crypto.randomUUID();
|
| 17 |
+
localStorage.setItem(UNIQUE_ID_KEY, uniqueId);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return uniqueId;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Get user ID from localStorage
|
| 25 |
+
*/
|
| 26 |
+
export function getUserId(): string | null {
|
| 27 |
+
return localStorage.getItem(USER_ID_KEY);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Set user ID in localStorage
|
| 32 |
+
*/
|
| 33 |
+
export function setUserId(userId: string): void {
|
| 34 |
+
localStorage.setItem(USER_ID_KEY, userId);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Clear user data from localStorage
|
| 39 |
+
*/
|
| 40 |
+
export function clearUserData(): void {
|
| 41 |
+
localStorage.removeItem(USER_ID_KEY);
|
| 42 |
+
localStorage.removeItem(UNIQUE_ID_KEY);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Check if user is registered (has user_id)
|
| 47 |
+
*/
|
| 48 |
+
export function isUserRegistered(): boolean {
|
| 49 |
+
return getUserId() !== null;
|
| 50 |
+
}
|
| 51 |
+
|