added PEK pages

This commit is contained in:
Ida Dittrich 2025-12-01 17:01:25 +01:00
parent 8a0e5f88a1
commit 101b3063c0
207 changed files with 11488 additions and 11968 deletions

View file

@ -0,0 +1,623 @@
# API Routes Documentation
## Overview
This documentation covers the Chat Playground and Workflow management API endpoints. These routes provide functionality for managing AI workflows, chat interactions, messages, logs, and related data.
## Authentication
All endpoints require authentication via the `getCurrentUser` dependency, which validates the user's session/token. Rate limiting is applied at 120 requests per minute for all endpoints.
---
## Chat Playground Routes
Base path: `/api/chat/playground`
### 1. Start Workflow
**Endpoint:** `POST /api/chat/playground/start`
**Description:** Starts a new workflow or continues an existing one based on the provided input.
**Query Parameters:**
- `workflowId` (optional, string): ID of an existing workflow to continue
- `workflowMode` (string, default: "Actionplan"): Workflow execution mode
- `"Actionplan"`: Traditional task planning workflow
- `"React"`: Iterative react-style processing
**Request Body:** `UserInputRequest` object containing:
```json
{
"input": "user's input text",
"files": [...], // optional file attachments
"metadata": {...} // optional metadata
}
```
**Response:** `ChatWorkflow` object representing the created or continued workflow
**Example Request:**
```bash
POST /api/chat/playground/start?workflowMode=Actionplan
Content-Type: application/json
{
"input": "Analyze the quarterly sales data",
"files": [...],
"metadata": {}
}
```
**Example Response:**
```json
{
"id": "wf_123456",
"userId": "user_abc",
"status": "running",
"mode": "Actionplan",
"messageIds": [...],
"logIds": [...],
"createdAt": 1234567890.0,
...
}
```
**Use Cases:**
- **User:** Initiating a new AI conversation or task
- **AI:** Starting automated workflow processing for scheduled tasks
- **Integration:** Continuing an interrupted workflow from a previous session
---
### 2. Stop Workflow
**Endpoint:** `POST /api/chat/playground/{workflowId}/stop`
**Description:** Stops a running workflow, allowing graceful termination of ongoing processes.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to stop
**Response:** `ChatWorkflow` object with updated status
**Example Request:**
```bash
POST /api/chat/playground/wf_123456/stop
```
**Example Response:**
```json
{
"id": "wf_123456",
"status": "stopped",
"stoppedAt": 1234567890.0,
...
}
```
**Use Cases:**
- **User:** Manually stopping a long-running task
- **AI:** Implementing timeout mechanisms
- **System:** Cleanup of abandoned workflows
---
### 3. Get Unified Chat Data
**Endpoint:** `GET /api/chat/playground/{workflowId}/chatData`
**Description:** Retrieves unified chat data including messages, logs, and statistics in chronological order. Supports incremental data fetching.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `afterTimestamp` (optional, float): Unix timestamp to fetch only data created after this time
**Response:** Dictionary containing:
- `messages`: Array of chat messages
- `logs`: Array of log entries
- `stats`: Array of statistics
- `documents`: Array of attached documents
- All sorted by `_createdAt` timestamp
**Example Request:**
```bash
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567800.0
```
**Example Response:**
```json
{
"messages": [
{
"id": "msg_001",
"content": "Hello",
"_createdAt": 1234567890.0,
...
}
],
"logs": [
{
"id": "log_001",
"level": "info",
"message": "Processing started",
"_createdAt": 1234567891.0,
...
}
],
"stats": [...],
"documents": [...]
}
```
**Use Cases:**
- **User:** Real-time chat UI updates and polling for new data
- **AI:** Monitoring workflow progress and synchronizing state
- **Mobile App:** Efficient incremental data synchronization
---
## Workflow Routes
Base path: `/api/workflows`
### 1. List All Workflows
**Endpoint:** `GET /api/workflows/`
**Description:** Retrieves all workflows for the currently authenticated user.
**Response:** Array of `ChatWorkflow` objects
**Example Request:**
```bash
GET /api/workflows/
```
**Example Response:**
```json
[
{
"id": "wf_001",
"userId": "user_abc",
"status": "completed",
"createdAt": 1234567890.0,
...
},
{
"id": "wf_002",
"userId": "user_abc",
"status": "running",
"createdAt": 1234567990.0,
...
}
]
```
**Use Cases:**
- **User:** Dashboard showing all their workflows
- **AI:** Analyzing user's workflow history and patterns
- **Admin:** User activity monitoring
---
### 2. Get Workflow by ID
**Endpoint:** `GET /api/workflows/{workflowId}`
**Description:** Retrieves detailed information about a specific workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Response:** `ChatWorkflow` object
**Example Request:**
```bash
GET /api/workflows/wf_123456
```
**Example Response:**
```json
{
"id": "wf_123456",
"userId": "user_abc",
"status": "running",
"mode": "Actionplan",
"messageIds": ["msg_001", "msg_002"],
"logIds": ["log_001", "log_002"],
"createdAt": 1234567890.0,
"updatedAt": 1234567990.0,
...
}
```
**Use Cases:**
- **User:** Viewing specific workflow details
- **AI:** Loading workflow state for processing
- **Debugging:** Investigating workflow behavior
---
### 3. Update Workflow
**Endpoint:** `PUT /api/workflows/{workflowId}`
**Description:** Updates workflow properties. Requires ownership or modification permissions.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to update
**Request Body:** Dictionary with fields to update:
```json
{
"name": "Updated workflow name",
"description": "Updated description",
"tags": ["tag1", "tag2"],
...
}
```
**Response:** Updated `ChatWorkflow` object
**Example Request:**
```bash
PUT /api/workflows/wf_123456
Content-Type: application/json
{
"name": "Sales Analysis Q4",
"tags": ["sales", "analysis"]
}
```
**Example Response:**
```json
{
"id": "wf_123456",
"name": "Sales Analysis Q4",
"tags": ["sales", "analysis"],
...
}
```
**Use Cases:**
- **User:** Renaming workflows for better organization
- **User:** Adding tags and metadata
- **AI:** Programmatically updating workflow metadata
---
### 4. Get Workflow Status
**Endpoint:** `GET /api/workflows/{workflowId}/status`
**Description:** Gets the current status of a workflow without loading all associated data.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Response:** `ChatWorkflow` object (lightweight status check)
**Example Request:**
```bash
GET /api/workflows/wf_123456/status
```
**Use Response:**
```json
{
"id": "wf_123456",
"status": "running",
"currentStep": "analyzing",
"progress": 45,
"updatedAt": 1234567990.0
}
```
**Use Cases:**
- **User:** Quick status checks without loading full data
- **AI:** Monitoring workflow progress
- **Mobile:** Efficient polling for status updates
---
### 5. Get Workflow Logs
**Endpoint:** `GET /api/workflows/{workflowId}/logs`
**Description:** Retrieves logs for a workflow with support for selective data transfer.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `logId` (optional, string): Log ID to fetch only newer logs (selective data transfer)
**Response:** Array of `ChatLog` objects
**Example Request (All Logs):**
```bash
GET /api/workflows/wf_123456/logs
```
**Example Request (Incremental):**
```bash
GET /api/workflows/wf_123456/logs?logId=log_050
```
**Example Response:**
```json
[
{
"id": "log_001",
"workflowId": "wf_123456",
"level": "info",
"message": "Workflow started",
"timestamp": 1234567890.0,
...
},
{
"id": "log_002",
"workflowId": "wf_123456",
"level": "warning",
"message": "Slow response detected",
"timestamp": 1234567900.0,
...
}
]
```
**Use Cases:**
- **User:** Viewing detailed execution logs for debugging
- **AI:** Error analysis and troubleshooting
- **System:** Audit trail and compliance
---
### 6. Get Workflow Messages
**Endpoint:** `GET /api/workflows/{workflowId}/messages`
**Description:** Retrieves messages for a workflow with support for selective data transfer.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
**Query Parameters:**
- `messageId` (optional, string): Message ID to fetch only newer messages
**Response:** Array of `ChatMessage` objects
**Example Request (All Messages):**
```bash
GET /api/workflows/wf_123456/messages
```
**Example Request (Incremental):**
```bash
GET /api/workflows/wf_123456/messages?messageId=msg_100
```
**Example Response:**
```json
[
{
"id": "msg_001",
"workflowId": "wf_123456",
"role": "user",
"content": "Analyze the data",
"timestamp": 1234567890.0,
"files": [...],
...
},
{
"id": "msg_002",
"workflowId": "wf_123456",
"role": "assistant",
"content": "Processing your request...",
"timestamp": 1234567900.0,
...
}
]
```
**Use Cases:**
- **User:** Viewing conversation history
- **AI:** Context building for responses
- **UI:** Chat interface message display
---
### 7. Delete Workflow
**Endpoint:** `DELETE /api/workflows/{workflowId}`
**Description:** Deletes a workflow and all its associated data (messages, logs, etc.). Requires ownership or deletion permissions.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456
```
**Example Response:**
```json
{
"id": "wf_123456",
"message": "Workflow and associated data deleted successfully"
}
```
**Use Cases:**
- **User:** Cleanup of old or unnecessary workflows
- **AI:** Automated cleanup of expired workflows
- **Admin:** Data management and compliance
---
### 8. Delete Workflow Message
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}`
**Description:** Deletes a specific message from a workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
- `messageId` (required, string): ID of the message to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456/messages/msg_050
```
**Example Response:**
```json
{
"workflowId": "wf_123456",
"messageId": "msg_050",
"message": "Message deleted successfully"
}
```
**Use Cases:**
- **User:** Removing sensitive or incorrect messages
- **AI:** Content moderation
- **User:** Editing conversation history
---
### 9. Delete File from Message
**Endpoint:** `DELETE /api/workflows/{workflowId}/messages/{messageId}/files/{fileId}`
**Description:** Deletes a file reference from a message within a workflow.
**Path Parameters:**
- `workflowId` (required, string): ID of the workflow
- `messageId` (required, string): ID of the message
- `fileId` (required, string): ID of the file to delete
**Response:** Dictionary with deletion confirmation
**Example Request:**
```bash
DELETE /api/workflows/wf_123456/messages/msg_050/files/file_123
```
**Example Response:**
```json
{
"workflowId": "wf_123456",
"messageId": "msg_050",
"fileId": "file_123",
"message": "File reference deleted successfully"
}
```
**Use Cases:**
- **User:** Removing attached files for privacy
- **User:** Fixing incorrect file uploads
- **System:** Managing storage quotas
---
## Error Handling
All endpoints may return the following HTTP status codes:
- `200 OK`: Successful request
- `400 Bad Request`: Invalid request parameters
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `500 Internal Server Error`: Server error
**Example Error Response:**
```json
{
"detail": "Error message describing what went wrong"
}
```
---
## Best Practices
### For Users:
1. **Polling Strategy**: Use the `afterTimestamp` or `logId`/`messageId` parameters to implement efficient polling without redundant data transfer
2. **Error Handling**: Always implement proper error handling and user feedback
3. **Rate Limiting**: Be aware of the 120 requests/minute limit and implement exponential backoff
4. **Cleanup**: Regularly delete old workflows to manage storage
### For AI/Automation:
1. **Incremental Updates**: Always use selective data transfer parameters to minimize bandwidth
2. **Status Checks**: Use lightweight endpoints like `/status` for monitoring
3. **Timeout Handling**: Implement proper stop logic for long-running workflows
4. **Logging**: Monitor logs for debugging and performance analysis
### For Developers:
1. **Caching**: Cache workflow status to reduce API calls
2. **Webhooks**: Consider implementing webhooks for real-time updates instead of polling
3. **Batch Operations**: Group related operations to reduce network overhead
4. **Error Recovery**: Implement retry logic with exponential backoff
---
## Integration Examples
### Starting a Workflow with File Upload
```bash
# Start workflow
POST /api/chat/playground/start?workflowMode=Actionplan
{
"input": "Analyze this report",
"files": [{"id": "file_001", "name": "report.pdf"}]
}
# Poll for updates
GET /api/chat/playground/wf_123456/chatData
# Continue polling with incremental updates
GET /api/chat/playground/wf_123456/chatData?afterTimestamp=1234567890.0
```
### Monitoring Workflow Progress
```bash
# Start workflow
POST /api/chat/playground/start
# Check status periodically
GET /api/workflows/wf_123456/status
# Get detailed logs if status indicates issues
GET /api/workflows/wf_123456/logs
# Stop if needed
POST /api/chat/playground/wf_123456/stop
```
### Cleanup Old Workflows
```bash
# List all workflows
GET /api/workflows/
# Filter for old/completed workflows and delete
DELETE /api/workflows/wf_old_123
DELETE /api/workflows/wf_old_124
```

83
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",
"dotenv": "^16.0.3",
@ -19,18 +20,22 @@
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"motion": "^12.7.3",
"pg": "^8.8.0",
"proj4": "^2.20.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.7.1",
"xstate": "^5.20.1"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/node": "^24.7.2",
"@types/proj4": "^2.5.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
@ -1073,6 +1078,17 @@
"node": ">= 8"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1426,6 +1442,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1433,6 +1455,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "24.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
@ -1443,6 +1474,13 @@
"undici-types": "~7.14.0"
}
},
"node_modules/@types/proj4": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@types/proj4/-/proj4-2.5.6.tgz",
"integrity": "sha512-zfMrPy9fx+8DchqM0kIUGeu2tTVB5ApO1KGAYcSGFS8GoqRIkyL41xq2yCx/iV3sOLzo7v4hEgViSLTiPI1L0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
@ -3683,6 +3721,12 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -3840,6 +3884,12 @@
"node": ">= 0.6"
}
},
"node_modules/mgrs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
"integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -4371,6 +4421,19 @@
"node": ">= 0.8.0"
}
},
"node_modules/proj4": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz",
"integrity": "sha512-ipfBRfQly0HhHTO7hnC1GfaX8bvroO7VV4KH889ehmADSE8C/qzp2j+Jj6783S9Tj6c2qX/hhYm7oH0kgXzBAA==",
"license": "MIT",
"dependencies": {
"mgrs": "1.0.0",
"wkt-parser": "^1.5.1"
},
"funding": {
"url": "https://github.com/sponsors/ahocevar"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -4524,6 +4587,20 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -5302,6 +5379,12 @@
"node": ">= 8"
}
},
"node_modules/wkt-parser": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.2.tgz",
"integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==",
"license": "MIT"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View file

@ -16,6 +16,7 @@
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0",
"axios": "^1.8.3",
"dotenv": "^16.0.3",
@ -25,18 +26,22 @@
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"motion": "^12.7.3",
"pg": "^8.8.0",
"proj4": "^2.20.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.7.1",
"xstate": "^5.20.1"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/node": "^24.7.2",
"@types/proj4": "^2.5.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",

View file

@ -7,9 +7,11 @@ import './index.css';
import Login from './pages/Login';
import Register from './pages/Register';
import { AuthProvider } from './auth/authProvider';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { LanguageProvider } from './contexts/LanguageContext';
import { AuthProvider } from './providers/auth/AuthProvider';
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
import { FileProvider } from './contexts/FileContext';
import Home from './pages/Home/Home';
function App() {
@ -37,21 +39,25 @@ function App() {
return (
<LanguageProvider>
<AuthProvider>
<Router>
<Routes>
{/* Public route */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={
<ProtectedRoute>
<Home />
</ProtectedRoute>
}>
{/* All page routing is now handled by the Page Loader in Home.tsx */}
<Route path="*" element={null} />
</Route>
</Routes>
</Router>
<FileProvider>
<WorkflowSelectionProvider>
<Router>
<Routes>
{/* Public route */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={
<ProtectedRoute>
<Home />
</ProtectedRoute>
}>
{/* All page routing is now handled by the Page Loader in Home.tsx */}
<Route path="*" element={null} />
</Route>
</Routes>
</Router>
</WorkflowSelectionProvider>
</FileProvider>
</AuthProvider>
</LanguageProvider>
);

View file

@ -1,6 +1,7 @@
// api.ts
import axios from 'axios';
import { addCSRFTokenToHeaders } from './utils/csrfUtils';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
import { clearUserDataCache } from './utils/userCache';
// Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
@ -52,12 +53,29 @@ api.interceptors.request.use(
}
}
// Authentication is now handled automatically via httpOnly cookies
// Browser will send cookies automatically with credentials: 'include'
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
// Check for auth token in localStorage and add to headers
const authToken = localStorage.getItem('authToken');
if (authToken && config.headers) {
config.headers.Authorization = `Bearer ${authToken}`;
console.log('🔑 Using Bearer token for authentication');
} else {
// Fallback: httpOnly cookies
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
}
// Add CSRF token to all requests (except GET requests)
if (config.method && ['post', 'put', 'patch', 'delete'].includes(config.method.toLowerCase())) {
// Add CSRF token to all requests (including GET requests for certain endpoints)
// Some endpoints like /api/realestate/* require CSRF tokens even for GET requests
const method = config.method?.toLowerCase();
const url = config.url || '';
const requiresCSRF =
['post', 'put', 'patch', 'delete'].includes(method || '') ||
url.includes('/api/realestate/');
if (requiresCSRF) {
// Ensure CSRF token exists, generate one if missing
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(config.headers as Record<string, string>);
}
@ -80,8 +98,8 @@ api.interceptors.response.use(
if (!isLoginEndpoint) {
// Clear local auth data (httpOnly cookies are cleared by backend)
localStorage.removeItem('auth_authority');
localStorage.removeItem('currentUser');
sessionStorage.removeItem('auth_authority');
clearUserDataCache();
// Redirect to login
window.location.href = '/login';
}

309
src/api/workflowApi.ts Normal file
View file

@ -0,0 +1,309 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
// Workflow interfaces
export interface Workflow {
id: string;
mandateId: string;
status: string;
name?: string;
workflowMode?: string;
[key: string]: any; // Allow additional properties
}
export interface WorkflowMessage {
[key: string]: any;
}
export interface WorkflowStats {
[key: string]: any;
}
export interface WorkflowDocument {
[key: string]: any;
}
export interface WorkflowLog {
[key: string]: any;
}
// Request/Response interfaces based on API documentation
export interface FileAttachment {
id: string;
name: string;
}
export interface UserInputRequest {
input: string;
files?: FileAttachment[]; // optional file attachments - array of {id, name} objects
metadata?: Record<string, any>; // optional metadata
}
export interface StartWorkflowRequest {
prompt: string;
listFileId?: string[]; // Array of file ID strings (files must be uploaded first via /api/files/upload)
userLanguage?: string; // Optional, defaults to "en"
metadata?: Record<string, any>;
}
export interface StartWorkflowResponse extends Workflow {
// Workflow object returned from start endpoint
}
export interface ChatDataResponse {
messages: WorkflowMessage[];
logs: WorkflowLog[];
stats: WorkflowStats[];
documents: WorkflowDocument[];
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch all workflows for the current user
* Endpoint: GET /api/workflows/
*/
export async function fetchWorkflows(request: ApiRequestFunction): Promise<Workflow[]> {
const data = await request<Workflow[]>({
url: '/api/workflows/',
method: 'get'
});
return Array.isArray(data) ? data : [];
}
/**
* Fetch a single workflow by ID
* Endpoint: GET /api/workflows/{workflowId}
*/
export async function fetchWorkflow(
request: ApiRequestFunction,
workflowId: string
): Promise<Workflow> {
return await request<Workflow>({
url: `/api/workflows/${workflowId}`,
method: 'get'
});
}
/**
* Fetch workflow status (lightweight status check)
* Endpoint: GET /api/workflows/{workflowId}/status
*/
export async function fetchWorkflowStatus(
request: ApiRequestFunction,
workflowId: string
): Promise<Workflow> {
return await request<Workflow>({
url: `/api/workflows/${workflowId}/status`,
method: 'get'
});
}
/**
* Fetch workflow messages
* Endpoint: GET /api/workflows/{workflowId}/messages
* Query params: messageId (optional) - fetch only newer messages
*/
export async function fetchWorkflowMessages(
request: ApiRequestFunction,
workflowId: string,
messageId?: string
): Promise<WorkflowMessage[]> {
const params = messageId ? { messageId } : undefined;
const data = await request<WorkflowMessage[]>({
url: `/api/workflows/${workflowId}/messages`,
method: 'get',
params
});
return Array.isArray(data) ? data : [];
}
/**
* Fetch workflow logs
* Endpoint: GET /api/workflows/{workflowId}/logs
* Query params: logId (optional) - fetch only newer logs
*/
export async function fetchWorkflowLogs(
request: ApiRequestFunction,
workflowId: string,
logId?: string
): Promise<WorkflowLog[]> {
const params = logId ? { logId } : undefined;
const data = await request<WorkflowLog[]>({
url: `/api/workflows/${workflowId}/logs`,
method: 'get',
params
});
return Array.isArray(data) ? data : [];
}
/**
* Fetch unified chat data (messages, logs, stats, documents)
* Endpoint: GET /api/chat/playground/{workflowId}/chatData
* Query params: afterTimestamp (optional) - fetch only data created after this time
*/
export async function fetchChatData(
request: ApiRequestFunction,
workflowId: string,
afterTimestamp?: number
): Promise<ChatDataResponse> {
const params = afterTimestamp ? { afterTimestamp: afterTimestamp.toString() } : undefined;
const requestConfig = {
url: `/api/chat/playground/${workflowId}/chatData`,
method: 'get' as const,
params
};
console.log('📤 fetchChatData request:', requestConfig);
const data = await request<ChatDataResponse>(requestConfig);
console.log('📥 fetchChatData response:', data);
// Ensure all arrays exist
return {
messages: Array.isArray(data.messages) ? data.messages : [],
logs: Array.isArray(data.logs) ? data.logs : [],
stats: Array.isArray(data.stats) ? data.stats : [],
documents: Array.isArray(data.documents) ? data.documents : []
};
}
/**
* Start a new workflow or continue an existing one
* Endpoint: POST /api/chat/playground/start
* Query params: workflowId (optional), workflowMode (default: "Actionplan")
*/
export async function startWorkflowApi(
request: ApiRequestFunction,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Actionplan' | 'React' }
): Promise<StartWorkflowResponse> {
const params: Record<string, string> = {};
if (options?.workflowId) {
params.workflowId = options.workflowId;
}
if (options?.workflowMode) {
params.workflowMode = options.workflowMode;
}
const requestConfig = {
url: '/api/chat/playground/start',
method: 'post' as const,
data: workflowData,
params: Object.keys(params).length > 0 ? params : undefined
};
console.log('📤 startWorkflow request:', requestConfig);
const response = await request<StartWorkflowResponse>(requestConfig);
console.log('📥 startWorkflow response:', response);
return response;
}
/**
* Stop a running workflow
* Endpoint: POST /api/chat/playground/{workflowId}/stop
*/
export async function stopWorkflowApi(
request: ApiRequestFunction,
workflowId: string
): Promise<void> {
await request({
url: `/api/chat/playground/${workflowId}/stop`,
method: 'post'
});
}
/**
* Update workflow properties
* Endpoint: PUT /api/workflows/{workflowId}
*/
export async function updateWorkflowApi(
request: ApiRequestFunction,
workflowId: string,
updateData: Partial<{ name: string; description?: string; tags?: string[] }>
): Promise<Workflow> {
return await request<Workflow>({
url: `/api/workflows/${workflowId}`,
method: 'put',
data: updateData
});
}
/**
* Delete a workflow and all associated data
* Endpoint: DELETE /api/workflows/{workflowId}
*/
export async function deleteWorkflowApi(
request: ApiRequestFunction,
workflowId: string
): Promise<void> {
await request({
url: `/api/workflows/${workflowId}`,
method: 'delete'
});
}
/**
* Delete multiple workflows
*/
export async function deleteWorkflowsApi(
request: ApiRequestFunction,
workflowIds: string[]
): Promise<void> {
// Delete workflows one by one since there's no bulk delete endpoint
const deletePromises = workflowIds.map(workflowId =>
request({
url: `/api/workflows/${workflowId}`,
method: 'delete'
}).catch(error => {
console.error(`Failed to delete workflow ${workflowId}:`, error);
throw error;
})
);
await Promise.all(deletePromises);
}
/**
* Delete a message from a workflow
* Endpoint: DELETE /api/workflows/{workflowId}/messages/{messageId}
*/
export async function deleteMessageApi(
request: ApiRequestFunction,
workflowId: string,
messageId: string
): Promise<void> {
await request({
url: `/api/workflows/${workflowId}/messages/${messageId}`,
method: 'delete'
});
}
/**
* Delete a file reference from a message
* Endpoint: DELETE /api/workflows/{workflowId}/messages/{messageId}/files/{fileId}
*/
export async function deleteFileFromMessageApi(
request: ApiRequestFunction,
workflowId: string,
messageId: string,
fileId: string
): Promise<void> {
await request({
url: `/api/workflows/${workflowId}/messages/${messageId}/files/${fileId}`,
method: 'delete'
});
}

View file

@ -1,61 +0,0 @@
.editModal {
/* Custom styling for the edit modal */
}
.editForm {
/* Form-specific styling within the modal */
min-width: 400px;
}
/* Ensure proper spacing for form elements */
.editForm :global(.fieldGroup) {
margin-bottom: 18px;
}
.editForm :global(.floatingLabelInput) {
margin-bottom: 18px;
}
/* Style the readonly fields to blend better with the modal */
.editForm :global(.readonlyField) {
background-color: var(--color-bg);
opacity: 0.8;
font-style: italic;
}
/* Enhance button styling within the modal */
.editForm :global(.buttonGroup) {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid var(--color-primary);
}
.editForm :global(.saveButton) {
background-color: var(--color-secondary);
min-width: 120px;
}
.editForm :global(.saveButton:hover) {
background-color: var(--color-secondary-hover);
}
.editForm :global(.cancelButton) {
min-width: 100px;
}
/* Responsive design */
@media (max-width: 640px) {
.editForm {
min-width: 300px;
}
.editForm :global(.buttonGroup) {
gap: 8px;
}
.editForm :global(.saveButton),
.editForm :global(.cancelButton) {
flex: 1;
min-width: unset;
}
}

View file

@ -1,50 +0,0 @@
import { Popup, EditForm } from '../ui/Popup';
import styles from './ConnectionEditModal.module.css';
import { ConnectionEditModalProps } from './connectionsInterfaces';
import { useLanguage } from '../../contexts/LanguageContext';
export function ConnectionEditModal({
isOpen,
connection,
fields,
onSave,
onCancel
}: ConnectionEditModalProps) {
const { t } = useLanguage();
if (!connection) {
return null;
}
const authorityName = connection.authority?.charAt(0).toUpperCase() + connection.authority?.slice(1);
// Simplified approach for the title
const baseTitle = t('connections.edit_connection_title', `Edit {authority} Connection`);
const modalTitle = baseTitle.includes('{authority}')
? baseTitle.replace('{authority}', authorityName || '')
: `Edit ${authorityName} Connection`;
return (
<Popup
isOpen={isOpen}
title={modalTitle}
onClose={onCancel}
size="medium"
className={styles.editModal}
>
<EditForm
data={connection}
fields={fields}
onSave={onSave}
onCancel={onCancel}
saveButtonText={t('connections.update_connection', 'Update Connection')}
cancelButtonText={t('common.cancel', 'Cancel')}
showButtons={true}
className={styles.editForm}
/>
</Popup>
);
}
export default ConnectionEditModal;

View file

@ -1,47 +0,0 @@
.errorContainer {
margin-bottom: 20px;
}
.errorMessage {
color: var(--color-red, #ef4444);
padding: 12px 16px;
background-color: var(--color-red-hover, rgba(239, 68, 68, 0.1));
border: 1px solid var(--color-red, #ef4444);
border-radius: 25px;
margin-bottom: 12px;
font-size: 14px;
line-height: 1.4;
}
.errorMessage:last-child {
margin-bottom: 0;
}
.errorMessage strong {
font-weight: 600;
margin-right: 4px;
}
/* Animation for error appearance */
.errorMessage {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive design */
@media (max-width: 480px) {
.errorMessage {
padding: 10px 14px;
font-size: 13px;
}
}

View file

@ -1,41 +0,0 @@
import styles from './ConnectionsErrorDisplay.module.css';
import { ConnectionsErrorDisplayProps } from './connectionsInterfaces';
import { useLanguage } from '../../contexts/LanguageContext';
export function ConnectionsErrorDisplay({
error,
connectError,
disconnectError
}: ConnectionsErrorDisplayProps) {
const { t } = useLanguage();
// Don't render anything if no errors
if (!error && !connectError && !disconnectError) {
return null;
}
return (
<div className={styles.errorContainer}>
{error && (
<div className={styles.errorMessage}>
<strong>{t('connections.error', 'Error')}:</strong> {error}
</div>
)}
{connectError && (
<div className={styles.errorMessage}>
<strong>{t('connections.connection_error', 'Connection Error')}:</strong> {connectError}
</div>
)}
{disconnectError && (
<div className={styles.errorMessage}>
<strong>{t('connections.disconnect_error', 'Disconnect Error')}:</strong> {disconnectError}
</div>
)}
</div>
);
}
export default ConnectionsErrorDisplay;

View file

@ -1,61 +0,0 @@
.tableContainer {
width: 100%;
background: var(--color-bg);
border-radius: 8px;
overflow: hidden;
}
.connectionsTable {
width: 100%;
border-radius: 8px;
}
/* Override FormGenerator styles for connections-specific styling */
.connectionsTable :global(.table) {
background-color: var(--color-bg);
color: var(--color-text);
}
.connectionsTable :global(.th) {
background-color: var(--color-bg);
color: var(--color-text);
border-bottom: 2px solid var(--color-primary);
}
.connectionsTable :global(.td) {
background-color: var(--color-bg);
color: var(--color-text);
border-bottom: 1px solid var(--color-primary);
}
.connectionsTable :global(.tr:hover) {
background-color: var(--color-primary-hover);
}
/* Expired connection highlighting */
.connectionsTable :global(.expired-connection) {
background-color: rgba(255, 193, 7, 0.1) !important;
color: #ff6b35 !important;
font-weight: 600;
}
.connectionsTable :global(.tr:hover .expired-connection) {
background-color: rgba(255, 193, 7, 0.2) !important;
}
/* Responsive design */
@media (max-width: 768px) {
.tableContainer {
border-radius: 4px;
margin: 0 -8px;
}
}
@media (max-width: 480px) {
.tableContainer {
margin: 0 -16px;
border-radius: 0;
border-left: none;
border-right: none;
}
}

View file

@ -1,46 +0,0 @@
// import React from 'react';
import { FormGenerator } from '../FormGenerator';
import styles from './ConnectionsTable.module.css';
import { ConnectionsTableProps } from './connectionsInterfaces';
import { useLanguage } from '../../contexts/LanguageContext';
export function ConnectionsTable({
connections,
columns,
actions,
isLoading = false,
isConnecting = false,
isDisconnecting = false,
onRowSelect,
onDelete,
onDeleteMultiple
}: ConnectionsTableProps) {
const { t } = useLanguage();
const loading = isLoading || isConnecting || isDisconnecting;
return (
<div className={styles.tableContainer}>
<FormGenerator
data={connections}
columns={columns}
title={t('connections.service_connections', 'Service Connections')}
loading={loading}
searchable={true}
filterable={true}
sortable={true}
resizable={true}
pagination={true}
pageSize={10}
selectable={true}
onRowSelect={onRowSelect}
onDelete={onDelete}
onDeleteMultiple={onDeleteMultiple}
actionButtons={actions}
className={styles.connectionsTable}
/>
</div>
);
}
export default ConnectionsTable;

View file

@ -1,75 +0,0 @@
import { ColumnConfig } from '../FormGenerator';
import { EditFieldConfig } from '../ui/Popup';
// Re-export connection-related interfaces from hooks
export type { Connection, CreateConnectionData } from '../../hooks/useConnections';
import type { Connection } from '../../hooks/useConnections';
// Component Props Interfaces
export interface ConnectionsTableProps {
connections: Connection[];
columns: ColumnConfig[];
actions: TableAction[];
isLoading?: boolean;
isConnecting?: boolean;
isDisconnecting?: boolean;
onRowSelect?: (selectedRows: Connection[]) => void;
onDelete?: (connection: Connection) => Promise<void>;
onDeleteMultiple?: (connections: Connection[]) => Promise<void>;
}
export interface ConnectionEditModalProps {
isOpen: boolean;
connection: Connection | null;
fields: EditFieldConfig[];
onSave: (updatedConnection: Connection) => Promise<void>;
onCancel: () => void;
}
export interface ConnectionsErrorDisplayProps {
error?: string | null;
connectError?: string | null;
disconnectError?: string | null;
}
// Table Action Interface
export interface TableAction {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
title?: string | ((connection: Connection) => string);
onAction?: (connection: Connection) => Promise<void> | void;
disabled?: (connection: Connection) => boolean | { disabled: boolean; message?: string };
loading?: (connection: Connection) => boolean;
}
// Connection Status Types
export type ConnectionStatus = 'active' | 'pending' | 'expired' | 'revoked';
export type ConnectionAuthority = 'local' | 'google' | 'msft';
// Handler Function Types
export interface ConnectionHandlers {
handleCreateConnection: (type: 'msft' | 'google') => Promise<void>;
handleConnect: (connection: Connection) => Promise<void>;
handleDisconnect: (connection: Connection) => Promise<void>;
handleDelete: (connection: Connection) => Promise<void>;
handleDeleteMultiple: (connections: Connection[]) => Promise<void>;
handleUpdateConnection: (connection: Connection) => Promise<void>;
handleEditConnection: (connection: Connection) => Promise<void>;
handleSaveConnection: (updatedConnection: Connection) => Promise<void>;
handleCancelEdit: () => void;
}
// Hook Return Types
export interface ConnectionsLogicReturn extends ConnectionHandlers {
connections: Connection[];
isLoading: boolean;
isConnecting: boolean;
isDisconnecting: boolean;
error: string | null;
connectError: string | null;
disconnectError: string | null;
editPopupOpen: boolean;
editingConnection: Connection | null;
connectionColumns: ColumnConfig[];
connectionEditFields: EditFieldConfig[];
tableActions: TableAction[];
}

View file

@ -1,479 +0,0 @@
import { useEffect, useState } from 'react';
import { useConnections, useOAuthConnect, useDisconnect } from '../../hooks/useConnections';
import { useLanguage } from '../../contexts/LanguageContext';
import { ColumnConfig } from '../FormGenerator';
import { EditFieldConfig } from '../ui/Popup';
import {
Connection,
CreateConnectionData,
ConnectionsLogicReturn,
TableAction
} from './connectionsInterfaces';
export function useConnectionsLogic(): ConnectionsLogicReturn {
const { t } = useLanguage();
// Hooks
const {
connections,
fetchConnections,
createConnection,
updateConnection,
deleteConnection,
refreshMicrosoftToken,
refreshGoogleToken,
isLoading,
error
} = useConnections();
const {
connectWithPopup,
isConnecting,
error: connectError
} = useOAuthConnect();
const {
disconnect,
isDisconnecting,
error: disconnectError
} = useDisconnect();
// Local state
const [editPopupOpen, setEditPopupOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
// Define field configuration for editing connections
const connectionEditFields: EditFieldConfig[] = [
{
key: 'authority',
label: t('connections.field.service', 'Service'),
type: 'readonly',
editable: false,
formatter: (value: string) => {
if (!value) return t('connections.unknown', 'Unknown');
const labels = {
'google': t('connections.service.google', 'Google'),
'msft': t('connections.service.microsoft', 'Microsoft'),
'local': t('connections.service.local', 'Local')
};
return labels[value as keyof typeof labels] || value;
}
},
{
key: 'status',
label: t('connections.field.status', 'Status'),
type: 'readonly',
editable: false,
formatter: (value: string) => value ? value.charAt(0).toUpperCase() + value.slice(1) : t('connections.unknown', 'Unknown')
},
{
key: 'externalUsername',
label: t('connections.field.external_username', 'External Username'),
type: 'string',
editable: true,
required: false,
placeholder: t('connections.placeholder.external_username', 'Enter external username')
},
{
key: 'externalEmail',
label: t('connections.field.external_email', 'External Email'),
type: 'email',
editable: true,
required: false,
placeholder: t('connections.placeholder.external_email', 'Enter external email address')
},
{
key: 'connectedAt',
label: t('connections.field.connected_at', 'Connected At'),
type: 'readonly',
editable: false,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
},
{
key: 'lastChecked',
label: t('connections.field.last_checked', 'Last Checked'),
type: 'readonly',
editable: false,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
}
];
// Define custom columns for the connections table
const connectionColumns: ColumnConfig[] = [
{
key: 'authority',
label: t('connections.field.service', 'Service'),
type: 'enum',
filterOptions: ['google', 'msft', 'local'],
formatter: (value: string) => {
if (!value) return t('connections.unknown', 'Unknown');
const labels = {
'google': t('connections.service.google', 'Google'),
'msft': t('connections.service.microsoft', 'Microsoft'),
'local': t('connections.service.local', 'Local')
};
return labels[value as keyof typeof labels] || value;
},
width: 150,
sortable: true,
filterable: true
},
{
key: 'status',
label: t('connections.field.status', 'Status'),
type: 'enum',
filterOptions: ['active', 'pending', 'expired', 'revoked'],
formatter: (value: string) => {
const status = value?.charAt(0).toUpperCase() + value?.slice(1) || t('connections.unknown', 'Unknown');
return status;
},
width: 120,
sortable: true,
filterable: true,
cellClassName: (value: string) => {
return value === 'expired' ? 'expired-connection' : '';
}
},
{
key: 'externalUsername',
label: t('connections.field.external_username', 'External Username'),
type: 'string',
width: 200,
sortable: true,
filterable: false,
searchable: true
},
{
key: 'externalEmail',
label: t('connections.field.external_email', 'External Email'),
type: 'string',
width: 250,
sortable: true,
filterable: false,
searchable: true
},
{
key: 'connectedAt',
label: t('connections.field.connected_at', 'Connected At'),
type: 'date',
width: 150,
sortable: true,
filterable: false,
searchable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
},
{
key: 'lastChecked',
label: t('connections.field.last_checked', 'Last Checked'),
type: 'date',
width: 150,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
},
{
key: 'expiresAt',
label: t('connections.field.expires_at', 'Expires At'),
type: 'date',
width: 150,
sortable: true,
filterable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
}
];
// Fetch connections on mount and auto-connect them
useEffect(() => {
const initializeConnections = async () => {
try {
await fetchConnections();
// Auto-connect all connections that are not active
const connectionsToConnect = connections.filter(conn =>
conn.status !== 'active' && conn.authority !== 'local'
);
for (const connection of connectionsToConnect) {
try {
await connectWithPopup(connection.id);
} catch (error) {
console.warn(`Failed to auto-connect ${connection.authority} connection:`, error);
}
}
} catch (error) {
console.error('Error initializing connections:', error);
}
};
initializeConnections();
}, []);
// Handler functions
const handleConnect = async (connection: Connection) => {
console.log('Connecting to service:', connection);
try {
await connectWithPopup(connection.id);
await fetchConnections();
} catch (error) {
console.error('Error connecting to service:', error);
}
};
const handleCreateConnection = async (type: 'msft' | 'google') => {
console.log('Creating connection for type:', type);
try {
// Get current UTC timestamp in seconds (float) to match backend
const currentTimestamp = Math.floor(Date.now() / 1000);
const connectionData: CreateConnectionData = {
type: type,
status: 'pending',
connectedAt: currentTimestamp,
lastChecked: currentTimestamp
};
console.log('Sending connection data to backend:', connectionData);
const newConnection = await createConnection(connectionData);
console.log('Connection created successfully:', newConnection);
handleConnect(newConnection);
await fetchConnections();
} catch (error) {
console.error('Error creating connection:', error);
}
};
const handleDisconnect = async (connection: Connection) => {
console.log('Disconnecting from service:', connection);
try {
await disconnect(connection.id);
await fetchConnections();
} catch (error) {
console.error('Error disconnecting from service:', error);
}
};
const handleDelete = async (connection: Connection) => {
const serviceName = connection.authority?.charAt(0).toUpperCase() + connection.authority?.slice(1) || t('connections.unknown', 'Unknown');
const confirmMessage = t('connections.confirm_delete', 'Are you sure you want to delete the {service} connection?').replace('{service}', serviceName);
if (window.confirm(confirmMessage)) {
try {
await deleteConnection(connection.id);
await fetchConnections();
} catch (error) {
console.error('Error deleting connection:', error);
}
}
};
const handleDeleteMultiple = async (connections: Connection[]) => {
const confirmMessage = t('connections.confirm_delete_multiple', 'Are you sure you want to delete {count} connections?').replace('{count}', connections.length.toString());
if (window.confirm(confirmMessage)) {
try {
// Delete all connections in parallel
await Promise.all(connections.map(connection => deleteConnection(connection.id)));
await fetchConnections();
} catch (error) {
console.error('Error deleting multiple connections:', error);
}
}
};
const handleUpdateConnection = async (connection: Connection) => {
console.log('Updating connection:', connection);
try {
if (connection.status === 'expired') {
// Refresh token based on connection type
if (connection.authority === 'msft') {
await refreshMicrosoftToken(connection.id);
} else if (connection.authority === 'google') {
await refreshGoogleToken(connection.id);
}
} else if (connection.status !== 'active') {
// If not active and not expired, try to connect
await handleConnect(connection);
}
await fetchConnections();
} catch (error) {
console.error('Error updating connection:', error);
}
};
const handleEditConnection = async (connection: Connection) => {
console.log('Editing connection:', connection);
setEditingConnection(connection);
setEditPopupOpen(true);
};
const handleSaveConnection = async (updatedConnection: Connection) => {
if (!editingConnection) return;
try {
const updateData = {
externalUsername: updatedConnection.externalUsername,
externalEmail: updatedConnection.externalEmail
};
await updateConnection(editingConnection.id, updateData);
console.log('Connection updated successfully');
await fetchConnections();
setEditPopupOpen(false);
setEditingConnection(null);
} catch (error) {
console.error('Error updating connection:', error);
}
};
const handleCancelEdit = () => {
setEditPopupOpen(false);
setEditingConnection(null);
};
// Table actions
const tableActions: TableAction[] = [
{
type: 'edit',
title: t('connections.action.edit', 'Edit'),
onAction: handleEditConnection
},
{
type: 'delete',
title: t('connections.action.delete', 'Delete')
// onAction is handled by FormGenerator for delete confirmation
}
] as any;
return {
// Data
connections,
isLoading,
isConnecting,
isDisconnecting,
error,
connectError,
disconnectError,
editPopupOpen,
editingConnection,
connectionColumns,
connectionEditFields,
tableActions,
// Handlers
handleCreateConnection,
handleConnect,
handleDisconnect,
handleDelete,
handleDeleteMultiple,
handleUpdateConnection,
handleEditConnection,
handleSaveConnection,
handleCancelEdit
};
}

View file

@ -1,10 +0,0 @@
// Export all components
export { ConnectionsTable } from './ConnectionsTable';
export { ConnectionEditModal } from './ConnectionEditModal';
export { ConnectionsErrorDisplay } from './ConnectionsErrorDisplay';
// Export logic hook
export { useConnectionsLogic } from './connectionsLogic';
// Export all interfaces and types
export * from './connectionsInterfaces';

View file

@ -1,21 +0,0 @@
import React from "react";
import { DashboardChatProps } from "./dashboardChatAreaTypes";
import DashboardChatArea from './DashboardChatArea.tsx';
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
const DashboardChat: React.FC<DashboardChatProps> = ({
workflowState,
workflowActions
}) => {
return (
<div className={styles.dashboard_chat}>
<DashboardChatArea
workflowState={workflowState}
workflowActions={workflowActions}
/>
</div>
);
};
export default DashboardChat;

View file

@ -1,61 +0,0 @@
import React, { useState } from "react";
import MessageList from "./DashboardChatAreaMessageList";
import InputArea from "./DashboardChatAreaInput";
import ConnectedFiles from "./DashboardChatAreaConnectedFiles";
import { DashboardChatAreaProps } from "./dashboardChatAreaTypes";
import styles from './DashboardChatAreaStyles/DashboardChat.module.css';
const DashboardChatArea: React.FC<DashboardChatAreaProps> = ({
workflowState,
workflowActions
}) => {
const [selectedFile, setSelectedFile] = useState<any>(null);
const [attachedFiles, setAttachedFiles] = useState<any[]>([]);
return (
<div className={styles.chat_grid}>
{/* Top Left: Message List */}
<div className={`${styles.quadrant} ${styles.messages_quadrant}`}>
<MessageList
workflowState={workflowState}
/>
</div>
{/*Top Right: File Preview (disabled for now)
<div className={`${styles.quadrant} ${styles.file_preview_quadrant}`}>
<FilePreview selectedFile={selectedFile} />
</div>
*/}
{/* Bottom Left: Input Area */}
<div className={`${styles.quadrant} ${styles.input_quadrant}`}>
<InputArea
workflowState={workflowState}
workflowActions={workflowActions}
attachedFiles={attachedFiles}
onAttachedFilesChange={setAttachedFiles}
/>
</div>
{/* Bottom Right: Connected Files */}
<div className={`${styles.quadrant} ${styles.connected_files_quadrant}`}>
<ConnectedFiles
onFileSelect={setSelectedFile}
selectedFile={selectedFile}
attachedFiles={attachedFiles}
onRemoveFile={(fileId) => {
// If the removed file is currently selected, clear the selection
if (selectedFile?.id === fileId) {
setSelectedFile(null);
}
// Remove the file from attached files
setAttachedFiles(files => files.filter(f => f.id !== fileId));
}}
/>
</div>
</div>
);
};
export default DashboardChatArea;

View file

@ -1,82 +0,0 @@
import React from 'react';
import { ConnectedFilesProps } from './dashboardChatAreaTypes';
import styles from './DashboardChatAreaStyles/DashboardChatConnectedFiles.module.css';
import { IoIosAttach } from 'react-icons/io';
const ConnectedFiles: React.FC<ConnectedFilesProps> = ({
onFileSelect,
selectedFile,
attachedFiles = [],
onRemoveFile
}) => {
const convertedFiles = attachedFiles.map(file => ({
id: file.id,
name: file.name,
mimeType: file.type,
size: file.size,
creationDate: new Date().toISOString(),
fileData: file.fileData,
objectUrl: file.objectUrl
}));
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
return Math.round(bytes / (1024 * 1024)) + ' MB';
};
return (
<div className={styles.container}>
{attachedFiles.length > 0 && (
<div className={styles.attachedInfo}>
<IoIosAttach className={styles.attachedInfoIcon}/> {attachedFiles.length} file{attachedFiles.length !== 1 ? 's' : ''} attached for workflow
</div>
)}
{convertedFiles.length === 0 ? (
<p className={styles.emptyState}>
No files connected to this workflow
</p>
) : (
<div className={styles.filesList}>
{convertedFiles.map((file) => (
<div
key={file.id}
onClick={() => onFileSelect?.(file)}
className={`${styles.fileItem} ${selectedFile?.id === file.id ? styles.selected : ''} ${styles.attached}`}
>
<div className={styles.fileInfo}>
<div className={styles.fileName}>
{file.name}
</div>
<div className={styles.fileSize}>
{file.size ? formatFileSize(file.size) : 'Unknown size'}
</div>
</div>
<div className={styles.fileActions}>
{onRemoveFile && (
<button
onClick={(e) => {
e.stopPropagation();
onRemoveFile(file.id);
}}
className={styles.removeButton}
title="Remove from attachment"
>
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ConnectedFiles;

View file

@ -1,192 +0,0 @@
import React from 'react';
import { useFilePreview } from '../../../hooks/useWorkflows';
import { FileInfo } from './dashboardChatAreaTypes';
interface AttachedFileWithData extends FileInfo {
fileData?: File;
objectUrl?: string;
}
interface FilePreviewProps {
selectedFile?: AttachedFileWithData | null;
}
const FilePreview: React.FC<FilePreviewProps> = ({ selectedFile }) => {
const { previewContent, fileMetadata, isLoading, error, fetchPreview } = useFilePreview();
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
// Handle base64 image data from backend
React.useEffect(() => {
if (fileMetadata && fileMetadata.base64Encoded && fileMetadata.preview) {
const isImage = fileMetadata.mimeType?.startsWith('image/') ||
selectedFile?.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/);
if (isImage) {
const dataUrl = `data:${fileMetadata.mimeType || 'image/png'};base64,${fileMetadata.preview}`;
setImageUrl(dataUrl);
}
}
}, [fileMetadata, selectedFile]);
React.useEffect(() => {
// Clean up previous object URL
const currentImageUrl = imageUrl;
if (currentImageUrl && currentImageUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentImageUrl);
}
setImageUrl(null);
if (selectedFile?.id) {
// Check if it's an image file (either from mimeType or file extension)
const isImage = selectedFile.mimeType?.startsWith('image/') ||
selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/);
if (isImage) {
// If it's an attached file with file data, create object URL for preview
if (selectedFile.fileData) {
const url = URL.createObjectURL(selectedFile.fileData);
setImageUrl(url);
} else if (selectedFile.objectUrl) {
setImageUrl(selectedFile.objectUrl);
} else if (selectedFile.downloadUrl) {
setImageUrl(selectedFile.downloadUrl);
} else {
// For existing uploaded files, fetch the image data
fetchPreview(selectedFile.id);
}
} else {
// For non-image files, try to fetch preview
fetchPreview(selectedFile.id);
}
}
// Cleanup function
return () => {
if (currentImageUrl && currentImageUrl.startsWith('blob:')) {
URL.revokeObjectURL(currentImageUrl);
}
};
}, [selectedFile?.id, selectedFile?.fileData, selectedFile?.objectUrl]);
// Cleanup on unmount
React.useEffect(() => {
return () => {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
}
};
}, []);
const getFileType = (mimeType?: string) => {
if (!mimeType) return 'Unknown';
if (mimeType.startsWith('image/')) return 'Image';
if (mimeType.startsWith('text/')) return 'Text';
if (mimeType.includes('pdf')) return 'PDF';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'Spreadsheet';
return 'Document';
};
return (
<div style={{ padding: '16px', height: '100%', overflow: 'auto' }}>
{!selectedFile && (
<p style={{ color: 'var(--color-gray)', textAlign: 'center' }}>
Select a file to preview
</p>
)}
{selectedFile && (
<div>
<div style={{
padding: '12px',
backgroundColor: 'var(--color-surface)',
borderRadius: '8px',
marginBottom: '16px'
}}>
<h4 style={{ margin: '0 0 8px 0' }}>{selectedFile.name}</h4>
<div style={{ fontSize: '12px', color: 'var(--color-gray)' }}>
Type: {getFileType(selectedFile.mimeType)}
Size: {selectedFile.size ? Math.round(selectedFile.size / 1024) + ' KB' : 'Unknown'}
</div>
</div>
{/* Image Preview - Show first for images */}
{(selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) && imageUrl ? (
<div style={{ marginBottom: '16px' }}>
<img
src={imageUrl}
alt={selectedFile.name}
style={{
maxWidth: '100%',
maxHeight: '500px',
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid var(--color-gray-disabled)',
display: 'block',
margin: '0 auto'
}}
onLoad={() => console.log('Image loaded successfully')}
onError={(e) => {
console.error('Image failed to load:', e);
console.log('Image URL:', imageUrl);
}}
/>
</div>
) : (selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) ? (
<div style={{
padding: '20px',
textAlign: 'center',
backgroundColor: 'var(--color-surface)',
borderRadius: '8px',
marginBottom: '16px'
}}>
<p>🖼 Image preview loading...</p>
<small style={{ color: 'var(--color-gray)' }}>
Debug: imageUrl={imageUrl ? 'yes' : 'no'}, downloadUrl={selectedFile.downloadUrl ? 'yes' : 'no'},
fileData={selectedFile.fileData ? 'yes' : 'no'}, objectUrl={selectedFile.objectUrl ? 'yes' : 'no'}
<br />
MimeType: {selectedFile.mimeType}
</small>
</div>
) : null}
{/* Text/Code Preview - Only for non-images and when we don't have an image URL */}
{!(selectedFile.mimeType?.startsWith('image/') || selectedFile.name?.toLowerCase().match(/\.(png|jpg|jpeg|gif|bmp|webp)$/)) && !imageUrl && (
<>
{isLoading && <p>Loading preview...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{previewContent && (
<div style={{
backgroundColor: 'var(--color-surface)',
padding: '16px',
borderRadius: '8px',
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: '1.4',
whiteSpace: 'pre-wrap',
overflow: 'auto',
maxHeight: '400px'
}}>
{previewContent}
</div>
)}
{!previewContent && !isLoading && !error && (
<p style={{ color: 'var(--color-gray)' }}>
Preview not available for this file type
</p>
)}
</>
)}
</div>
)}
</div>
);
};
export default FilePreview;

View file

@ -1,364 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import FileAttachmentPopup from './FileAttachmentPopup';
import { InputAreaProps, AttachedFile } from './dashboardChatAreaTypes';
import { useLanguage } from '../../../contexts/LanguageContext';
import { usePrompts } from '../../../hooks/usePrompts';
import styles from './DashboardChatAreaStyles/DashboardChatAreaInput.module.css';
import sharedStyles from './DashboardChatAreaStyles/DashboardChat.module.css';
const InputArea: React.FC<InputAreaProps> = ({
workflowState,
workflowActions,
attachedFiles: externalAttachedFiles = [],
onAttachedFilesChange
}) => {
const { t } = useLanguage();
const { prompts, loading: promptsLoading } = usePrompts();
const [inputValue, setInputValue] = useState('');
const [showFilePopup, setShowFilePopup] = useState(false);
const [isSending, setIsSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
// Always use external attached files from parent component
const currentAttachedFiles = externalAttachedFiles;
// Auto-resize textarea function
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to auto to get the actual scroll height
textarea.style.height = 'auto';
// Calculate the height based on content
const scrollHeight = textarea.scrollHeight;
const lineHeight = 1.5 * 14; // 1.5em * 14px font size
const padding = 32; // 16px top + 16px bottom padding
const minHeight = lineHeight * 4 + padding; // 4 rows
const maxHeight = lineHeight * 8 + padding; // 8 rows
// Set height within constraints
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
textarea.style.height = `${newHeight}px`;
};
// Get the current selected prompt from workflow state
const currentSelectedPrompt = workflowState.selectedPrompt;
// Auto-fill input when prompt is selected
useEffect(() => {
if (currentSelectedPrompt) {
setInputValue(currentSelectedPrompt.content);
}
}, [currentSelectedPrompt]);
// Adjust height when input value changes
useEffect(() => {
adjustTextareaHeight();
}, [inputValue]);
// Initial resize on mount
useEffect(() => {
adjustTextareaHeight();
}, []);
const handleSend = async () => {
if (!inputValue.trim() || isSending) return;
setIsSending(true);
setSendError(null);
try {
const fileIds = currentAttachedFiles.map(f => f.id);
let success = false;
if (workflowState.currentWorkflowId) {
success = await workflowActions.continueWorkflow(inputValue, fileIds);
} else {
const newWorkflowId = await workflowActions.startNewWorkflow(inputValue, fileIds);
success = !!newWorkflowId;
}
if (success) {
setInputValue('');
if (onAttachedFilesChange) {
onAttachedFilesChange([]);
}
// Prompt was used
} else {
setSendError('Failed to send message. Please try again.');
}
} catch (error: any) {
console.error('Failed to send message:', error);
setSendError(error.message || 'Failed to send message. Please try again.');
} finally {
setIsSending(false);
}
};
const handleStop = async () => {
// Stop the workflow but keep it selected
await workflowActions.stopWorkflow();
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleFilesAttached = (files: AttachedFile[]) => {
setShowFilePopup(false);
if (onAttachedFilesChange) {
onAttachedFilesChange(files);
}
};
const handlePromptSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const promptId = e.target.value;
if (promptId === '') {
workflowActions.clearPrompt();
setInputValue('');
} else {
const prompt = prompts.find(p => p.id === promptId);
if (prompt) {
workflowActions.selectPrompt(prompt);
}
}
};
const handleClearPrompt = () => {
workflowActions.clearPrompt();
setInputValue('');
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only hide drag over if we're leaving the drop zone entirely
if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Set the drop effect to copy
e.dataTransfer.dropEffect = 'copy';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (shouldShowStopButton) {
// Don't allow file drops when workflow is active
return;
}
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
// Convert File objects to AttachedFile format
const attachedFiles: AttachedFile[] = files.map((file, index) => ({
id: (Date.now() + index).toString(), // Simple ID generation
name: file.name,
size: file.size,
type: file.type,
fileData: file,
objectUrl: URL.createObjectURL(file)
}));
// Add to existing attached files
const updatedFiles = [...currentAttachedFiles, ...attachedFiles];
if (onAttachedFilesChange) {
onAttachedFilesChange(updatedFiles);
}
}
};
// Check if workflow is in an active state where we should show stop button
const isWorkflowActive = workflowState.workflow &&
['running', 'processing', 'started'].includes(workflowState.workflow.status);
const shouldShowStopButton = !!isWorkflowActive;
// Determine if label should be in focused/moved state
const shouldLabelBeFocused = isFocused || inputValue.trim().length > 0;
// Get placeholder text
const placeholderText = workflowState.currentWorkflowId
? t('chat.input.continue_workflow')
: t('chat.input.enter_message');
return (
<div
ref={dropZoneRef}
className={`${styles.input_area_container} ${isDragOver ? styles.drag_over : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Error messages */}
{(sendError || workflowState.error) && (
<div className={styles.error_message}>
{t('chat.input.error_prefix')} {sendError || workflowState.error}
</div>
)}
{/* Drag and drop overlay */}
{isDragOver && (
<div className={`${styles.drag_overlay} ${shouldShowStopButton ? styles.disabled : ''}`}>
<div className={styles.drag_overlay_content}>
<div className={styles.drag_icon}>
{shouldShowStopButton ? '🚫' : '📁'}
</div>
<div className={styles.drag_text}>
{shouldShowStopButton
? t('chat.input.drop_disabled')
: t('chat.input.drop_files_here')
}
</div>
</div>
</div>
)}
{/* Prompt selection dropdown */}
<div className={styles.prompt_selection_container}>
<div className={styles.prompt_dropdown_wrapper}>
<select
value={currentSelectedPrompt?.id || ''}
onChange={handlePromptSelect}
disabled={isSending || shouldShowStopButton || promptsLoading}
className={styles.prompt_dropdown}
>
<option value="">{promptsLoading ? t('chat.input.loading_prompts') : t('chat.input.select_prompt')}</option>
{prompts && Array.isArray(prompts) && prompts.map(prompt => (
<option key={prompt.id} value={prompt.id}>
{prompt.name}
</option>
))}
</select>
{currentSelectedPrompt && (
<button
onClick={handleClearPrompt}
disabled={isSending || shouldShowStopButton}
className={styles.clear_prompt_button}
title={t('chat.input.clear_prompt')}
>
</button>
)}
</div>
</div>
<div className={styles.input_form_container}>
<div className={styles.floating_label_textarea}>
<label
className={shouldLabelBeFocused ? styles.textarea_label_focused : styles.textarea_label}
>
{placeholderText}
</label>
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setTimeout(adjustTextareaHeight, 0);
}}
onKeyPress={handleKeyPress}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder=""
disabled={isSending || shouldShowStopButton}
className={`${styles.message_textarea} ${
!isFocused && inputValue.trim().length > 0 ? styles.message_textarea_with_content : ''
}`}
rows={4}
/>
</div>
<div className={styles.input_actions_row}>
<button
onClick={() => setShowFilePopup(true)}
disabled={isSending || shouldShowStopButton}
className={`${sharedStyles.button_secondary} ${
(isSending || shouldShowStopButton) ? styles.disabled : ''
}`}
>
{t('chat.input.attach_files')}
</button>
{shouldShowStopButton ? (
<button
onClick={handleStop}
disabled={isSending}
className={`${sharedStyles.button_primary} ${
isSending ? styles.disabled : styles.enabled
}`}
>
{isSending ? t('chat.input.stopping') : t('chat.input.stop')}
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
className={`${sharedStyles.button_primary} ${
(!inputValue.trim() || isSending)
? styles.disabled
: styles.enabled
}`}
>
{isSending ? t('chat.input.sending') :
workflowState.currentWorkflowId ? t('chat.input.continue') : t('chat.input.send')}
</button>
)}
{workflowState.currentWorkflowId && !shouldShowStopButton && (
<button
onClick={() => workflowActions.clearWorkflow()}
className={sharedStyles.button_primary}
>
{t('chat.input.new_chat')}
</button>
)}
</div>
</div>
{/* File Attachment Popup */}
{showFilePopup && (
<FileAttachmentPopup
onClose={() => setShowFilePopup(false)}
onFilesSelected={handleFilesAttached}
currentAttachedFiles={currentAttachedFiles}
/>
)}
</div>
);
};
export default InputArea;

View file

@ -1,107 +0,0 @@
import React from "react";
import { WorkflowLog } from "./dashboardChatAreaTypes";
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
interface LogItemProps {
log: WorkflowLog;
index: number;
}
const LogItem: React.FC<LogItemProps> = ({ log }) => {
// Format timestamp with robust parsing (same logic as MessageList)
const formatTimestamp = (timestamp: any) => {
if (!timestamp) {
return 'No timestamp';
}
// Handle different timestamp formats (same as safeParseDate)
let dateToTry = timestamp;
// If it's a number, check if it's in seconds or milliseconds
if (typeof timestamp === 'number') {
// If it's a 10-digit number, it's likely seconds since epoch
if (timestamp < 10000000000) {
dateToTry = timestamp * 1000; // Convert seconds to milliseconds
} else {
dateToTry = timestamp; // Already in milliseconds
}
}
// If it's a string that looks like a number, parse it and handle seconds/milliseconds
else if (typeof timestamp === 'string' && /^\d+$/.test(timestamp)) {
const numericTimestamp = parseInt(timestamp);
// If it's a 10-digit number, it's likely seconds since epoch
if (numericTimestamp < 10000000000) {
dateToTry = numericTimestamp * 1000; // Convert seconds to milliseconds
} else {
dateToTry = numericTimestamp; // Already in milliseconds
}
}
// If it's already a Date object
else if (timestamp instanceof Date) {
dateToTry = timestamp;
}
const date = new Date(dateToTry);
// Check if the date is valid
if (isNaN(date.getTime())) {
console.warn(`⚠️ LogItem: Invalid timestamp detected:`, {
originalTimestamp: timestamp,
type: typeof timestamp,
processedTimestamp: dateToTry
});
return `Invalid: ${timestamp}`;
}
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
let formatted = '';
if (isToday) {
formatted = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
formatted = date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return formatted;
};
// Determine log level for styling
const logLevel = log.level || (log.type?.toLowerCase() as 'info' | 'warning' | 'error' | 'debug') || 'info';
// Debug: Log what the LogItem is receiving
return (
<div className={`${messageStyles.log_container} ${messageStyles[logLevel]}`}>
<div className={messageStyles.log_header}>
<span className={`${messageStyles.log_level} ${messageStyles[logLevel]}`}>
{logLevel.toUpperCase()}
</span>
<span className={messageStyles.log_timestamp}>
{formatTimestamp(log.timestamp)}
</span>
</div>
<div className={messageStyles.log_message}>
{log.message}
</div>
{log.source && (
<div className={messageStyles.log_source}>
Source: {log.source}
</div>
)}
</div>
);
};
export default LogItem;

View file

@ -1,189 +0,0 @@
import React, { useState } from "react";
import { Message, Document } from "./dashboardChatAreaTypes";
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
import { FilePreview } from '../../FilePreview';
interface MessageItemProps {
message: Message;
index: number;
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const MessageItem: React.FC<MessageItemProps> = ({ message }) => {
const [previewModalOpen, setPreviewModalOpen] = useState<boolean>(false);
const [previewingFile, setPreviewingFile] = useState<Document | null>(null);
const handlePreview = (doc: Document) => {
console.log('👁️ Opening preview for document:', doc);
// Extract the UUID file ID from the downloadUrl (same as files page)
let actualFileId: string | null = null;
if (doc.downloadUrl) {
// Extract UUID from the downloadUrl: /api/workflows/files/{UUID}/download
const match = doc.downloadUrl.match(/\/api\/workflows\/files\/([^\/]+)\/download/);
if (match) {
actualFileId = match[1];
console.log(`🔗 Extracted UUID from downloadUrl: ${actualFileId}`);
} else {
console.error('Could not extract file ID from downloadUrl:', doc.downloadUrl);
alert('Could not extract file ID from download URL');
return;
}
} else if (doc.id) {
// Fallback to using the document id directly
actualFileId = doc.id;
console.log(`🔗 Using document id as fileId: ${actualFileId}`);
} else {
console.error('No downloadUrl or id available in document');
alert('No file ID available for preview');
return;
}
if (!actualFileId) {
console.error('Could not determine actual file ID');
alert('Could not determine file ID for preview');
return;
}
// Create a modified document with the correct file ID
const documentWithFileId = {
...doc,
id: actualFileId
};
setPreviewingFile(documentWithFileId);
setPreviewModalOpen(true);
};
const handleClosePreview = () => {
console.log('❌ Closing preview');
setPreviewModalOpen(false);
setPreviewingFile(null);
};
const handleDocumentClick = (doc: Document) => {
console.log('📋 Document clicked, opening preview:', doc);
handlePreview(doc);
};
const hasDocuments = message.documents && message.documents.length > 0;
const formatTimestamp = (ts?: string) => {
if (!ts) return '';
const date = new Date(ts);
if (isNaN(date.getTime())) return '';
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
return isToday
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const isUser = message.role === 'user';
return (
<>
<div className={`${messageStyles.message_item} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_header}>
{isUser ? 'You' : message.agentName}
{message.timestamp && (
<span className={messageStyles.message_timestamp_inline}>
{formatTimestamp(message.timestamp)}
</span>
)}
</div>
<div className={`${messageStyles.message_bubble} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={messageStyles.message_content}>
{message.content || <span className={messageStyles.message_no_content}>[No message content]</span>}
</div>
{hasDocuments && (
<div className={`${messageStyles.message_documents} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
<div className={`${messageStyles.message_documents_header} ${isUser ? messageStyles.user : messageStyles.assistant}`}>
{isUser ? 'Uploaded' : 'Attached'} Files ({message.documents?.length || 0})
</div>
<div>
{message.documents!.map((doc, i) => {
return (
<div
key={doc.id || i}
className={messageStyles.message_document_item}
onClick={() => handleDocumentClick(doc)}
title={`Click to preview ${doc.name}`}
style={{
cursor: 'pointer'
}}
>
<div className={messageStyles.message_document_info}>
<div className={messageStyles.message_document_name}>
{doc.ext ? `${doc.name}.${doc.ext}` : doc.name}
</div>
{doc.size && (
<div className={messageStyles.message_document_size}>
{formatFileSize(doc.size)}
</div>
)}
</div>
<div className={messageStyles.message_document_actions}>
{/* Preview and Download buttons disabled for now
<button
onClick={(e) => handlePreview(doc, e)}
className={messageStyles.message_document_action_button}
title="Preview file"
>
<div className={messageStyles.message_document_action_icon}>
<FaEye size={16} />
</div>
</button>
<button
onClick={(e) => handleDownload(doc, e)}
disabled={isDownloading}
className={messageStyles.message_document_action_button}
title="Download file"
>
<div className={messageStyles.message_document_action_icon}>
<FaDownload size={16} />
</div>
</button>
*/}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* File Preview Modal */}
{previewingFile && (
<FilePreview
isOpen={previewModalOpen}
onClose={handleClosePreview}
fileId={previewingFile.id || ''}
fileName={previewingFile.ext ? `${previewingFile.name}.${previewingFile.ext}` : previewingFile.name}
mimeType={previewingFile.type}
/>
)}
</>
);
};
export default MessageItem;

View file

@ -1,198 +0,0 @@
import React from 'react';
import { MessageListProps } from './dashboardChatAreaTypes';
import { useApiRequest } from '../../../hooks/useApi';
import MessageItem from './DashboardChatAreaMessageItem';
import LogItem from './DashboardChatAreaLogItem';
import { mergeMessagesAndLogs, transformWorkflowMessage } from './dashboardChatAreaProgressBar';
import messageStyles from './DashboardChatAreaStyles/DashboardChatMessages.module.css';
import { IoIosArrowDown, IoIosChatbubbles } from 'react-icons/io';
import { useLanguage } from '../../../contexts/LanguageContext';
const MessageList: React.FC<MessageListProps> = ({ workflowState }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [messages, setMessages] = React.useState<any[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isScrolledUp, setIsScrolledUp] = React.useState(false);
const lastCountRef = React.useRef(0);
const [showProgress, setShowProgress] = React.useState(false);
const [progressText, setProgressText] = React.useState('');
const [progressPercent, setProgressPercent] = React.useState(0);
const [maxTasks, setMaxTasks] = React.useState(0);
React.useEffect(() => {
const pending = workflowState.pendingMessages || [];
const logs = workflowState.logs || [];
if ((pending.length > 0 || logs.length > 0) && !showProgress) {
setShowProgress(true);
setProgressPercent(0);
setMaxTasks(0);
}
if (logs.length === 0 && pending.length > 0) {
setProgressText(t('chat.messages.loading_progress'));
return;
}
if (logs.length > 0) {
let total = 0;
let completed = 0;
logs.forEach(log => {
const msg = log.message || '';
const startMatch = msg.match(/Executing task (\d+)\/(\d+)/i);
if (startMatch) total = Math.max(total, parseInt(startMatch[2]));
const completeMatch = msg.match(/✅.*Task (\d+).*completed/i);
if (completeMatch) completed = Math.max(completed, parseInt(completeMatch[1]));
});
if (total > 0) {
const percent = Math.round((completed / total) * 100);
setProgressText(`${completed}/${total} ${t('chat.messages.tasks')} (${percent}%)`);
if (completed > maxTasks) {
setMaxTasks(completed);
setProgressPercent(percent);
}
} else {
setProgressText(`${t('chat.messages.analyzing_workflow')} (${logs.length} ${t('chat.messages.logs')})`);
}
}
}, [workflowState.pendingMessages, workflowState.logs, showProgress, maxTasks, t]);
React.useEffect(() => {
if (!workflowState.currentWorkflowId) {
setShowProgress(false);
setProgressText('');
setProgressPercent(0);
setMaxTasks(0);
}
}, [workflowState.currentWorkflowId]);
const timeline = React.useMemo(() => {
return mergeMessagesAndLogs(messages, workflowState.logs || []);
}, [messages, workflowState.logs]);
React.useEffect(() => {
const transform = async () => {
const pending = workflowState.pendingMessages || [];
const backend = workflowState.messages || [];
const all = [...pending, ...backend];
if (all.length === 0) {
setMessages([]);
return;
}
setIsLoading(true);
try {
const transformed = await Promise.all(all.map(msg => transformWorkflowMessage(msg, request)));
setMessages(transformed);
} catch (error) {
console.error('❌ Message transformation failed:', error);
setMessages([]);
} finally {
setIsLoading(false);
}
};
transform();
}, [workflowState.messages, workflowState.pendingMessages, request]);
const checkScroll = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
setIsScrolledUp(scrollHeight - scrollTop - clientHeight > 100);
}, []);
const scrollToBottom = React.useCallback(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, []);
React.useEffect(() => {
const count = timeline.length;
if (count > lastCountRef.current && !isScrolledUp) {
setTimeout(scrollToBottom, 100);
}
lastCountRef.current = count;
}, [timeline.length, isScrolledUp, scrollToBottom]);
const { currentWorkflowId, isLoading: workflowLoading, error } = workflowState;
const isEmpty = timeline.length === 0 && !workflowLoading && !isLoading && !currentWorkflowId;
return (
<div className={messageStyles.message_list_container}>
<div
ref={scrollRef}
className={isEmpty ? messageStyles.chat_messages_empty : messageStyles.chat_messages}
onScroll={checkScroll}
>
{error && <div className={messageStyles.message_error}>Error: {error}</div>}
<div className={messageStyles.messages_container}>
{timeline.map((item, index) => {
if (item.type === 'message') {
return (
<MessageItem
key={`message-${item.item.id}`}
message={item.item}
index={index}
/>
);
} else if (item.type === 'log') {
return (
<LogItem
key={`log-${item.item.id}`}
log={item.item}
index={index}
/>
);
}
return null;
})}
</div>
{isEmpty && (
<div className={messageStyles.message_empty_state}>
<div className={messageStyles.message_empty_state_icon}>
<IoIosChatbubbles />
</div>
<h3>{t('chat.messages.no_workflow_selected')}</h3>
<p>{t('chat.messages.no_workflow_selected_description')}</p>
</div>
)}
</div>
{showProgress && (
<div className={messageStyles.workflow_progress_container}>
<div className={messageStyles.workflow_progress_label}>
<span>{t('chat.messages.workflow_progress')}</span>
<span>{progressText}</span>
</div>
<div className={messageStyles.workflow_progress_bar}>
<div
className={messageStyles.workflow_progress_fill}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<button
className={`${messageStyles.scroll_to_bottom_btn} ${
(isScrolledUp && timeline.length > 0) ? messageStyles.visible : messageStyles.hidden
}`}
onClick={scrollToBottom}
title={t('chat.messages.scroll_to_bottom_btn')}
>
<IoIosArrowDown className={messageStyles.scroll_to_bottom_btn_icon} />
</button>
</div>
);
};
export default MessageList;

View file

@ -1,233 +0,0 @@
.dashboard_chat {
display: flex;
flex-direction: column;
align-self: stretch;
background: var(--color-bg);
position: relative;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
font-family: var(--font-family);
}
/* Grid Layout */
.chat_grid {
display: grid;
width: 100%;
height: 100%;
grid-template-rows: 1fr auto;
grid-template-columns: 2fr 1fr;
gap: 0px;
overflow: hidden;
box-sizing: border-box;
position: relative;
}
.quadrant {
overflow: hidden;
background-color: var(--color-bg);
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
box-sizing: border-box;
}
/* Quadrant specific styles */
.messages_quadrant {
grid-row: 1;
grid-column: 1;
}
.file_preview_quadrant {
grid-row: 1;
grid-column: 2;
}
.input_quadrant {
grid-row: 2;
grid-column: 1;
}
.connected_files_quadrant {
grid-row: 2;
grid-column: 2;
max-height: 200px; /* Fixed height for the connected files area */
overflow: hidden; /* Ensure no overflow at quadrant level */
}
/* Chat Messages styles moved to DashboardChatMessages.module.css */
/* Chat Input */
.chat_input {
width: 100%;
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
transition: all 0.2s ease;
box-sizing: border-box;
}
.chat_input.drag_over {
background-color: var(--color-secondary-disabled);
border: 2px dashed var(--color-secondary);
border-radius: 12px;
padding: 8px;
}
.input_row {
display: flex;
gap: 10px;
align-items: flex-end;
width: 100%;
}
.message_input {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--color-gray-disabled);
border-radius: 12px;
outline: none;
font-size: 14px;
font-family: var(--font-family);
background-color: var(--color-bg);
color: var(--color-text);
}
.message_input:focus {
border-color: var(--color-secondary);
}
.message_input:disabled {
background-color: var(--color-surface);
cursor: not-allowed;
opacity: 0.6;
}
.button_secondary {
border: 2px dashed var(--color-secondary);
border-radius: 30px;
padding: 10px 20px;
background: var(--color-bg);
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
text-align: center;
font-family: var(--font-family);
color: var(--color-secondary);
}
.button_secondary:hover {
background-color: var(--color-secondary);
color: var(--color-bg);
}
.button_secondary:disabled {
background-color: var(--color-bg);
cursor: not-allowed;
opacity: 0.6;
color: var(--color-secondary);
}
.button_primary {
border-radius: 30px;
border: 1px solid var(--color-secondary);
background: var(--color-secondary);
color: var(--color-bg);
border: none;
outline: none;
padding: 10px 20px;
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
transition: background-color 0.2s ease;
font-family: var(--font-family);
cursor: pointer;
min-width: 100px;
align-items: center;
justify-content: center;
}
.button_primary:hover {
background-color: var(--color-secondary-hover);
}
.button_primary:disabled {
background-color: var(--color-secondary-disabled);
border: 1px solid var(--color-secondary-disabled);
cursor: not-allowed;
opacity: 0.6;
}
/* Attached Files */
.attached_files {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
margin-bottom: 5px;
}
.attached_file {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background-color: var(--color-secondary-disabled);
border: 1px solid var(--color-secondary);
border-radius: 8px;
font-size: 12px;
color: var(--color-secondary);
font-family: var(--font-family);
}
.attached_file_icon {
font-size: 16px;
}
.attached_file_name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attached_file_remove {
background: none;
border: none;
color: var(--color-gray);
cursor: pointer;
padding: 0;
margin-left: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
transition: background-color 0.2s ease, color 0.2s ease;
}
.attached_file_remove:hover {
background-color: var(--color-gray-disabled);
color: var(--color-text);
}
/* Input Area styles moved to DashboardChatAreaInput.module.css */

View file

@ -1,259 +0,0 @@
/* Input Area Specific Styles */
.input_area_container {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
padding: 16px 16px 16px 0;
min-height: fit-content;
}
.workflow_status {
margin-bottom: 12px;
padding: 8px;
background-color: var(--color-surface);
border-radius: 4px;
font-size: 12px;
color: var(--color-gray);
}
.error_message {
padding: 8px;
background-color: var(--color-error, #ffe6e6);
color: var(--color-error-text, #d00);
border-radius: 4px;
margin-bottom: 12px;
}
/* Prompt Selection Styles */
.prompt_selection_container {
margin-bottom: 12px;
}
.prompt_dropdown_wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.prompt_dropdown {
flex: 1;
padding: 10px 16px;
padding-right: 40px; /* Space for the dropdown arrow */
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.8;
transition: border-color 0.2s ease, opacity 0.2s ease;
cursor: pointer;
appearance: none; /* Remove default dropdown arrow */
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M5 7l3 3 3-3'/></svg>"); background-repeat: no-repeat;
background-position: right 16px center; /* Position the custom arrow */
background-size: 12px;
}
.prompt_dropdown:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
}
.prompt_dropdown:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clear_prompt_button {
width: 32px;
height: 32px;
border: 1px solid var(--color-primary);
border-radius: 50%;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s ease;
}
.clear_prompt_button:hover {
background: var(--color-bg);
color: var(--color-secondary);
border: 1px solid var(--color-secondary);
}
.clear_prompt_button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.attached_files_count {
margin-bottom: 8px;
padding: 6px 10px;
background-color: var(--color-secondary-disabled);
border-radius: 25px;
font-size: 12px;
color: var(--color-bg);
text-align: center;
}
.input_form_container {
display: flex;
flex-direction: column;
gap: 12px;
}
.floating_label_textarea {
position: relative;
width: 100%;
box-sizing: border-box;
}
.textarea_label {
position: absolute;
left: 16px;
top: 16px;
color: var(--color-text);
opacity: 0.6;
font-size: 14px;
pointer-events: none;
transition: all 0.3s ease;
background-color: transparent;
font-family: var(--font-family);
z-index: 1;
}
.textarea_label_focused {
position: absolute;
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-secondary);
font-size: 12px;
pointer-events: none;
transition: all 0.3s ease;
background-color: var(--color-bg);
padding: 0 4px;
font-family: var(--font-family);
font-weight: 500;
z-index: 2;
}
.message_textarea {
resize: none;
width: 100%;
min-height: calc(1.5em * 4 + 32px); /* 4 rows + padding */
max-height: calc(1.5em * 8 + 32px); /* 8 rows + padding */
height: calc(1.5em * 4 + 32px); /* Start with 4 rows */
padding:16px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
transition: border-color 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
line-height: 1.5;
overflow-y: auto;
}
.message_textarea:focus {
outline: none;
border-color: var(--color-secondary);
opacity: 1;
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
}
.message_textarea::placeholder {
color: transparent;
}
.message_textarea:disabled {
opacity: 0.6;
}
.message_textarea_with_content {
opacity: 0.9;
border-color: var(--color-secondary);
background: var(--color-bg);
}
.input_actions_row {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.new_chat_button {
padding: 8px 12px;
background-color: var(--color-surface);
border: 1px solid var(--color-gray-disabled);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
color: var(--color-gray);
}
/* Drag and Drop Styles */
.input_area_container {
position: relative;
}
.drag_over {
border: 2px dashed var(--color-primary);
background-color: rgba(var(--color-primary-rgb), 0.05);
border-radius: 8px;
}
.drag_overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(var(--color-primary-rgb), 0.1);
backdrop-filter: blur(2px);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border: 2px dashed var(--color-primary);
}
.drag_overlay_content {
text-align: center;
color: var(--color-primary);
font-weight: 500;
}
.drag_icon {
font-size: 48px;
margin-bottom: 12px;
}
.drag_text {
font-size: 16px;
font-weight: 500;
}
.drag_overlay.disabled {
background-color: rgba(255, 0, 0, 0.1);
border-color: #ff6b6b;
}
.drag_overlay.disabled .drag_overlay_content {
color: #ff6b6b;
}

View file

@ -1,119 +0,0 @@
.container {
padding: 16px;
height: 100%;
max-height: 100%;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.attachedInfo {
margin-bottom: 12px;
padding: 8px 15px;
background-color: var(--color-secondary);
border-radius: 25px;
font-size: 14px;
color: var(--color-bg);
font-weight: 500;
flex-shrink: 0;
font-family: var(--font-family);
display: flex;
align-items: center;
gap: 4px;
}
.attachedInfoIcon {
width: 1.2rem;
height: 1.2rem;
}
.emptyState {
color: var(--color-gray);
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.filesList {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.fileItem {
font-family: var(--font-family);
font-weight: 500;
padding: 10px 15px;
border: 1px solid var(--color-primary);
border-radius: 25px;
cursor: pointer;
background-color: var(--color-bg);
display: flex;
align-items: center;
gap: 12px;
color: var(--color-text);
}
.fileInfo {
flex: 1;
min-width: 0;
color: var(--color-text);
}
.fileName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-family);
font-weight: 500;
color: var(--color-text);
}
.fileSize {
font-size: 12px;
color: var(--color-text);
}
.fileActions {
display: flex;
gap: 4px;
}
.removeButton {
padding: 4px 8px;
font-size: 12px;
background-color: transparent;
border: 1px solid var(--color-secondary);
border-radius: 15px;
cursor: pointer;
color: var(--color-secondary);
transition: all 0.3s ease;
}
.removeButton:hover {
padding: 4px 8px;
font-size: 12px;
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 15px;
cursor: pointer;
color: white;
}
.downloadButton {
padding: 4px 8px;
font-size: 12px;
background-color: transparent;
border: 1px solid var(--color-gray-disabled);
border-radius: 4px;
cursor: pointer;
}

View file

@ -1,550 +0,0 @@
/* Message-specific styles for DashboardChat components */
/* Message List Container - wraps the entire message list and positions scroll button */
.message_list_container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
/* Chat Messages Scrollable Area */
.chat_messages {
flex: 1;
padding: 15px;
border-radius: 15px;
margin-bottom: 15px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
}
.chat_messages_empty {
flex: 1;
padding: 15px;
border-radius: 15px;
margin-bottom: 15px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
display: flex;
justify-content: center;
align-items: center;
}
.message_empty_state_icon {
font-size: 90px;
color: var(--color-primary);
}
.message_empty_state h3 {
font-family: var(--font-family);
font-size: 1.2rem;
font-weight: 500;
color: var(--color-text);
}
.message_empty_state p {
font-family: var(--font-family);
font-size: 1rem;
font-weight: 400;
color: var(--color-text);
}
.chat_messages::-webkit-scrollbar {
width: 6px;
}
.chat_messages::-webkit-scrollbar-track {
background: transparent;
}
.chat_messages::-webkit-scrollbar-thumb {
background: var(--color-gray-disabled);
border-radius: 3px;
}
.chat_messages::-webkit-scrollbar-thumb:hover {
background: var(--color-gray);
}
/* Messages Container */
.messages_container {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 8px;
gap: 16px;
}
.messages_spacer {
flex: 1;
min-height: 20px;
}
/* Message Item Styles - Modern Chat Layout */
.message_item {
display: flex;
flex-direction: column;
max-width: 80%;
min-width: 0%;
margin-bottom: 4px;
position: relative;
}
/* Responsive design for smaller screens */
@media (max-width: 768px) {
.message_item {
max-width: 85%;
min-width: 150px;
}
}
@media (max-width: 480px) {
.message_item {
max-width: 95%;
min-width: 120px;
}
}
/* User messages - right aligned */
.message_item.user {
align-self: flex-end;
align-items: flex-end;
}
/* Assistant messages - left aligned */
.message_item.assistant {
align-self: flex-start;
align-items: flex-start;
}
.message_header {
font-size: 11px;
color: var(--color-gray);
margin-bottom: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.message_bubble {
padding: 12px 16px;
border-radius: 25px;
position: relative;
word-wrap: break-word;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.1);
width: 100%;
}
/* User message bubble */
.message_bubble.user {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-hover) 100%);
color: white;
border-bottom-right-radius: 4px;
}
/* Assistant message bubble */
.message_bubble.assistant {
background-color: var(--color-highlight-gray);
color: #181818;
border-bottom-left-radius: 4px;
}
.message_content {
line-height: 1.4;
white-space: pre-wrap;
font-size: 14px;
}
.message_timestamp {
font-size: 10px;
color: var(--color-gray);
margin-top: 4px;
opacity: 0.7;
}
.message_timestamp_inline {
font-size: 10px;
color: var(--color-gray);
opacity: 0.8;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
.message_no_content {
color: var(--color-gray);
font-style: italic;
}
/* Document/File Attachments in Messages */
.message_documents {
margin-top: 8px;
padding: 8px;
background-color: color-mix(in srgb, var(--color-primary), transparent 80%);
border-radius: 15px;
border: 1px solid var(--color-primary);
width: 100%;
box-sizing: border-box;
}
.message_documents_header {
font-size: 12px;
color: var(--color-gray);
margin-bottom: 8px;
font-weight: 500;
}
/* Document header styling for user messages */
.message_documents_header.user {
color: rgba(255, 255, 255, 0.8);
}
/* Document header styling for assistant messages */
.message_documents_header.assistant {
color: var(--color-gray);
}
.message_document_item {
display: flex;
align-items: center;
gap: 9px;
padding: 6px;
border-radius: 4px;
background-color: var(--color-highlight-gray);
margin-bottom: 4px;
cursor: pointer;
color: var(--color-text);
}
.message_document_info {
flex: 1;
min-width: 0;
}
.message_document_name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-gray);
}
.message_document_size {
font-size: 12px;
color: var(--color-gray);
}
.message_document_actions {
display: flex;
gap: 9px;
}
.message_document_action_button {
width: 33px;
height: 32px;
font-size: 12px;
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 25px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.message_document_action_icon {
font-size: 16px;
color: white;
}
.message_document_action:hover {
background-color: var(--color-gray-disabled);
}
.message_document_action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Scroll to Bottom Button */
.scroll_to_bottom_btn {
position: absolute;
bottom: 80px;
right: 20px;
background-color: var(--color-secondary);
color: white;
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
font-size: 20px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out, transform 0.2s ease-in-out;
}
.scroll_to_bottom_btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.scroll_to_bottom_btn:active {
transform: scale(0.95);
}
.scroll_to_bottom_btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.scroll_to_bottom_btn.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
/* Loading and Error States */
.message_loading {
text-align: center;
padding: 16px;
color: var(--color-gray);
}
.message_error {
padding: 8px;
background-color: var(--color-error);
color: white;
border-radius: 4px;
margin-bottom: 16px;
}
.message_empty_state {
color: var(--color-gray);
text-align: center;
justify-content: center;
padding: 16px;
}
/* Workflow Status Display */
.workflow_status {
padding: 8px;
background-color: var(--color-surface);
border-radius: 4px;
margin-bottom: 16px;
}
.workflow_status_title {
font-weight: bold;
}
.workflow_status_polling {
margin-left: 8px;
font-size: 12px;
color: var(--color-secondary);
opacity: 0.7;
}
/* Animation for polling indicator */
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.polling_indicator {
animation: pulse 2s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
@keyframes loadingSlide {
0% { transform: translateX(-100%); }
50% { transform: translateX(0%); }
100% { transform: translateX(100%); }
}
.workflow_progress_container {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid var(--color-primary);
border-bottom: 1px solid var(--color-primary);
margin-top: auto;
margin-right: 15px;
}
.workflow_progress_label {
font-family: var(--font-family);
font-size: 12px;
color: var(--color-text);
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.workflow_progress_breakdown {
font-size: 10px;
color: var(--color-gray);
margin-bottom: 6px;
text-align: center;
opacity: 0.8;
}
.workflow_progress_bar {
width: 100%;
height: 8px;
background-color: var(--color-gray-disabled);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.workflow_progress_fill {
height: 100%;
background: linear-gradient(90deg, var(--color-secondary) 0%, var(--color-secondary-hover) 100%);
border-radius: 4px;
transition: width 1.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.workflow_progress_fill.loading {
width: 100% !important;
background: linear-gradient(90deg, transparent, var(--color-gray-disabled), transparent);
animation: loadingSlide 2s infinite;
}
/* Enhanced shimmer animation for workflow progress */
.workflow_progress_fill::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shimmer 3s infinite;
}
/* Hide shimmer during loading state */
.workflow_progress_fill.loading::after {
display: none;
}
/* Workflow Log Messages */
.log_container {
margin-top: 8px;
padding: 20px;
border-radius: 25px;
font-size: 12px;
font-family: 'Courier New', monospace;
border-left: 3px solid;
border: 1px solid var(--color-gray-disabled);
background-color: var(--color-bg);
}
.log_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-weight: 600;
}
.log_level {
text-transform: uppercase;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
color: white;
}
.log_level.info {
background-color: var(--color-gray);
}
.log_level.success {
background-color: var(--color-secondary);
}
.log_level.warning {
background-color: var(--color-primary);
}
.log_level.error {
background-color: var(--color-red);
}
.log_level.debug {
background-color: var(--color-gray);
}
.log_timestamp {
font-size: 10px;
color: var(--color-gray);
opacity: 0.8;
}
.log_message {
line-height: 1.4;
color: var(--color-text);
white-space: pre-wrap;
}
.log_source {
font-size: 10px;
color: var(--color-gray);
margin-top: 4px;
opacity: 0.7;
}
.log_progress {
margin-top: 4px;
font-size: 10px;
color: var(--color-secondary);
font-weight: 500;
}
/* Log details (expandable) */
.log_details {
margin-top: 6px;
padding: 6px;
background-color: rgba(0, 0, 0, 0.03);
border-radius: 4px;
font-size: 11px;
color: var(--color-gray);
overflow-x: auto;
}
.log_details pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}

View file

@ -1,501 +0,0 @@
import React, { useState, useRef } from 'react';
import { useUserFiles, UserFile } from '../../../hooks/useFiles';
interface AttachedFile {
id: string;
name: string;
size: number;
type: string;
fileData?: File;
objectUrl?: string;
}
interface FileAttachmentPopupProps {
onClose: () => void;
onFilesSelected: (files: AttachedFile[]) => void;
currentAttachedFiles: AttachedFile[];
}
const FileAttachmentPopup: React.FC<FileAttachmentPopupProps> = ({
onClose,
onFilesSelected,
currentAttachedFiles
}) => {
const [selectedFiles, setSelectedFiles] = useState<AttachedFile[]>(currentAttachedFiles);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [activeTab, setActiveTab] = useState<'upload' | 'existing'>('upload');
const [fileSubTab, setFileSubTab] = useState<'all' | 'uploads' | 'created' | 'shared'>('all');
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: existingFiles, loading: filesLoading } = useUserFiles();
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files);
handleFileUpload(files);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
handleFileUpload(files);
};
const handleFileUpload = async (files: File[]) => {
setUploading(true);
// Create file objects with data for preview
const uploadedFiles: AttachedFile[] = files.map((file, index) => {
const fileObj: AttachedFile = {
id: (Date.now() + index).toString(),
name: file.name,
size: file.size,
type: file.type,
fileData: file
};
// Create object URL for images
if (file.type.startsWith('image/')) {
fileObj.objectUrl = URL.createObjectURL(file);
console.log('FileAttachmentPopup: Created object URL for', file.name, ':', fileObj.objectUrl);
}
console.log('FileAttachmentPopup: Created file object:', fileObj.name, 'Has fileData:', !!fileObj.fileData, 'Has objectUrl:', !!fileObj.objectUrl);
return fileObj;
});
// Add to selected files
setSelectedFiles(prev => [...prev, ...uploadedFiles]);
setUploading(false);
};
const toggleFileSelection = (file: UserFile) => {
const attachedFile: AttachedFile = {
id: file.id,
name: file.file_name,
size: file.size || 0,
type: 'application/octet-stream'
};
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.id === file.id);
if (isSelected) {
return prev.filter(f => f.id !== file.id);
} else {
return [...prev, attachedFile];
}
});
};
const handleConfirm = () => {
onFilesSelected(selectedFiles);
};
// Cleanup object URLs when component unmounts
React.useEffect(() => {
return () => {
selectedFiles.forEach(file => {
if (file.objectUrl) {
URL.revokeObjectURL(file.objectUrl);
}
});
};
}, []);
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
return Math.round(bytes / (1024 * 1024)) + ' MB';
};
// Simplified selectable files list
const SelectableFilesList: React.FC<{
files: UserFile[];
selectedFiles: AttachedFile[];
onFileSelect: (file: UserFile) => void;
activeTab: string;
}> = ({ files, selectedFiles, onFileSelect, activeTab }) => {
// Filter files based on active tab
const filteredFiles = files.filter(file => {
switch (activeTab) {
case 'uploads':
return file.source === 'user_uploaded';
case 'created':
return file.source === 'agent_created';
case 'shared':
return file.source === 'shared_with_me';
default:
return true;
}
});
const isFileSelected = (fileId: string) => {
return selectedFiles.some(f => f.id === fileId);
};
const getFileIcon = (fileName: string) => {
const extension = fileName.split('.').pop()?.toLowerCase();
switch (extension) {
case 'pdf': return '📄';
case 'doc': case 'docx': return '📝';
case 'xls': case 'xlsx': return '📊';
case 'jpg': case 'jpeg': case 'png': case 'gif': return '🖼️';
case 'mp4': case 'avi': return '🎥';
case 'txt': return '📄';
default: return '📎';
}
};
if (filteredFiles.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
No files found in this category
</div>
);
}
return (
<div>
{/* Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 80px 100px 80px',
padding: '12px',
fontWeight: '500',
borderBottom: '1px solid #eee',
backgroundColor: '#f8f9fa',
fontSize: '12px'
}}>
<div></div>
<div>Name</div>
<div>Type</div>
<div>Size</div>
<div>Date</div>
</div>
{/* File List */}
<div>
{filteredFiles.map(file => (
<div
key={file.id}
onClick={() => onFileSelect(file)}
style={{
display: 'grid',
gridTemplateColumns: '40px 1fr 80px 100px 80px',
padding: '12px',
borderBottom: '1px solid #f0f0f0',
cursor: 'pointer',
backgroundColor: isFileSelected(file.id) ? '#e3f2fd' : 'white',
border: isFileSelected(file.id) ? '1px solid var(--color-secondary)' : '1px solid transparent',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
if (!isFileSelected(file.id)) {
e.currentTarget.style.backgroundColor = '#f8f9fa';
}
}}
onMouseLeave={(e) => {
if (!isFileSelected(file.id)) {
e.currentTarget.style.backgroundColor = 'white';
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<input
type="checkbox"
checked={isFileSelected(file.id)}
onChange={() => {}}
style={{ margin: 0 }}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px' }}>{getFileIcon(file.file_name)}</span>
<span style={{ fontSize: '14px', fontWeight: '500' }}>{file.file_name}</span>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>{file.action}</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{file.size ? formatFileSize(file.size) : 'Unknown'}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{new Date(file.created_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
);
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
width: '600px',
maxHeight: '80vh',
overflow: 'hidden',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.15)'
}}>
{/* Header */}
<div style={{
padding: '20px',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0 }}>Attach Files</h3>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '18px',
cursor: 'pointer'
}}
>
</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid #eee'
}}>
<button
onClick={() => setActiveTab('upload')}
style={{
flex: 1,
padding: '12px',
border: 'none',
backgroundColor: activeTab === 'upload' ? '#f0f0f0' : 'white',
cursor: 'pointer',
borderBottom: activeTab === 'upload' ? '2px solid var(--color-secondary)' : 'none'
}}
>
Upload New Files
</button>
<button
onClick={() => setActiveTab('existing')}
style={{
flex: 1,
padding: '12px',
border: 'none',
backgroundColor: activeTab === 'existing' ? '#f0f0f0' : 'white',
cursor: 'pointer',
borderBottom: activeTab === 'existing' ? '2px solid var(--color-secondary)' : 'none'
}}
>
Select Existing Files
</button>
</div>
{/* Content */}
<div style={{ padding: '20px', maxHeight: '400px', overflowY: 'auto' }}>
{activeTab === 'upload' && (
<div>
{/* Drag and Drop Area */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: `2px dashed ${dragOver ? 'var(--color-secondary)' : '#ccc'}`,
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
backgroundColor: dragOver ? '#f0f8ff' : '#fafafa',
marginBottom: '20px',
cursor: 'pointer'
}}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📁</div>
<div style={{ fontSize: '16px', marginBottom: '8px' }}>
Drag and drop files here or click to browse
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
Supports all file types
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{uploading && (
<div style={{ textAlign: 'center', padding: '20px' }}>
Uploading files...
</div>
)}
</div>
)}
{activeTab === 'existing' && (
<div>
{/* File Category Sub-tabs */}
<div style={{
display: 'flex',
marginBottom: '16px',
borderBottom: '1px solid #eee'
}}>
{[
{ key: 'all', label: 'All Files' },
{ key: 'uploads', label: 'Uploads' },
{ key: 'created', label: 'AI Created' },
{ key: 'shared', label: 'Shared' }
].map(tab => (
<button
key={tab.key}
onClick={() => setFileSubTab(tab.key as any)}
style={{
flex: 1,
padding: '8px 12px',
border: 'none',
backgroundColor: fileSubTab === tab.key ? '#f0f0f0' : 'transparent',
cursor: 'pointer',
fontSize: '12px',
borderBottom: fileSubTab === tab.key ? '2px solid var(--color-secondary)' : '2px solid transparent'
}}
>
{tab.label}
</button>
))}
</div>
{filesLoading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
Loading files...
</div>
) : (
<div style={{
maxHeight: '300px',
overflow: 'auto',
border: '1px solid #eee',
borderRadius: '6px',
position: 'relative'
}}>
<SelectableFilesList
files={existingFiles}
selectedFiles={selectedFiles}
onFileSelect={toggleFileSelection}
activeTab={fileSubTab}
/>
</div>
)}
{/* Selection Instructions */}
<div style={{
marginTop: '12px',
padding: '8px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
fontSize: '12px',
color: '#666'
}}>
💡 Click on files to select them for attachment
</div>
</div>
)}
</div>
{/* Selected Files Summary */}
{selectedFiles.length > 0 && (
<div style={{
padding: '16px 20px',
backgroundColor: '#f8f9fa',
borderTop: '1px solid #eee'
}}>
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '8px' }}>
Selected Files ({selectedFiles.length}):
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto' }}>
{selectedFiles.map(file => (
<div key={file.id} style={{
fontSize: '12px',
color: '#666',
marginBottom: '2px'
}}>
📎 {file.name} ({formatFileSize(file.size)})
</div>
))}
</div>
</div>
)}
{/* Footer */}
<div style={{
padding: '20px',
borderTop: '1px solid #eee',
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}>
<button
onClick={onClose}
style={{
padding: '8px 16px',
border: '1px solid #ddd',
backgroundColor: 'white',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedFiles.length === 0}
style={{
padding: '8px 16px',
backgroundColor: selectedFiles.length === 0 ? '#ccc' : 'var(--color-secondary)',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: selectedFiles.length === 0 ? 'not-allowed' : 'pointer'
}}
>
Attach {selectedFiles.length} File{selectedFiles.length !== 1 ? 's' : ''}
</button>
</div>
</div>
</div>
);
};
export default FileAttachmentPopup;

View file

@ -1,210 +0,0 @@
import { WorkflowLog, WorkflowProgress, TimelineItem } from './dashboardChatAreaTypes';
const parseLogProgress = (msg: string) => {
const m = msg.trim();
const taskStart = m.match(/^Executing task (\d+)\/(\d+)$/i);
if (taskStart) return { taskNumber: parseInt(taskStart[1]), totalTasks: parseInt(taskStart[2]), type: 'task_start' as const };
const actionStart = m.match(/^Task (\d+) - Starting action (\d+)\/(\d+)$/i);
if (actionStart) return { taskNumber: parseInt(actionStart[1]), actionNumber: parseInt(actionStart[2]), totalActions: parseInt(actionStart[3]), type: 'action_start' as const };
const actionComplete = m.match(/^(?:✅\s+)?Task (\d+) - Action (\d+)\/(\d+) completed$/i);
if (actionComplete) return { taskNumber: parseInt(actionComplete[1]), actionNumber: parseInt(actionComplete[2]), totalActions: parseInt(actionComplete[3]), isCompleted: true, type: 'action_complete' as const };
const taskComplete = m.match(/^(?:🎯\s+)?Task (\d+)\/(\d+) completed$/i);
if (taskComplete) return { taskNumber: parseInt(taskComplete[1]), totalTasks: parseInt(taskComplete[2]), isCompleted: true, type: 'task_complete' as const };
return { type: 'unknown' as const };
};
export const calculateWorkflowProgress = (messages: any[], logs: WorkflowLog[] = []): WorkflowProgress | null => {
if (messages.length === 0 && logs.length === 0) return null;
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'user') return { current: 0, total: 0, percentage: 0, isLoading: true };
let totalTasks = 0;
const taskCounts: { [key: number]: { total: number; completed: Set<number> } } = {};
logs.forEach(log => {
if (!log.message) return;
const p = parseLogProgress(log.message);
if (p.type === 'task_start' && p.totalTasks) totalTasks = Math.max(totalTasks, p.totalTasks);
if (p.type === 'action_start' && p.taskNumber && p.totalActions) {
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: 0, completed: new Set() };
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.totalActions);
}
if (p.type === 'action_complete' && p.taskNumber && p.actionNumber && p.totalActions) {
if (!taskCounts[p.taskNumber]) taskCounts[p.taskNumber] = { total: p.totalActions, completed: new Set() };
taskCounts[p.taskNumber].completed.add(p.actionNumber);
taskCounts[p.taskNumber].total = Math.max(taskCounts[p.taskNumber].total, p.totalActions);
}
if (p.type === 'task_complete' && p.taskNumber && p.totalTasks) {
totalTasks = Math.max(totalTasks, p.totalTasks);
if (taskCounts[p.taskNumber]) {
const task = taskCounts[p.taskNumber];
for (let i = 1; i <= task.total; i++) task.completed.add(i);
}
}
});
messages.forEach(msg => {
if (msg.role !== 'assistant' || !msg.content) return;
const match = msg.content.match(/✅\s+Task (\d+) - Action\s+(?:(\d+)\/(\d+)|.*completed)/i);
if (match) {
const taskNum = parseInt(match[1]);
if (match[2] && match[3]) {
const actionNum = parseInt(match[2]);
const totalActions = parseInt(match[3]);
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: totalActions, completed: new Set() };
taskCounts[taskNum].completed.add(actionNum);
taskCounts[taskNum].total = Math.max(taskCounts[taskNum].total, totalActions);
} else {
if (!taskCounts[taskNum]) taskCounts[taskNum] = { total: 1, completed: new Set() };
taskCounts[taskNum].completed.add(1);
if (taskCounts[taskNum].total === 0) taskCounts[taskNum].total = 1;
}
}
});
let completed = 0;
for (let i = 1; i <= totalTasks; i++) {
const task = taskCounts[i];
if (task && task.total > 0 && task.completed.size === task.total) completed++;
}
return totalTasks > 0 ? { current: completed, total: totalTasks, percentage: Math.round((completed / totalTasks) * 100), isLoading: false } : null;
};
export const safeParseDate = (ts: any, fallback = Date.now()): Date => {
if (!ts) return new Date(fallback);
let d = ts;
if (typeof ts === 'number') d = ts < 10000000000 ? ts * 1000 : ts;
else if (typeof ts === 'string' && /^\d+$/.test(ts)) {
const n = parseInt(ts);
d = n < 10000000000 ? n * 1000 : n;
}
const date = new Date(d);
return isNaN(date.getTime()) ? new Date(fallback) : date;
};
export const mergeMessagesAndLogs = (messages: any[], logs: WorkflowLog[]): TimelineItem[] => {
const items: TimelineItem[] = [];
messages.forEach((msg, i) => {
const ts = safeParseDate(msg.timestamp || msg.publishedAt, Date.now() - ((messages.length + logs.length) - i) * 1000);
items.push({ type: 'message', item: msg, timestamp: ts });
});
logs.forEach((log, i) => {
const ts = safeParseDate(log.timestamp, Date.now() - (logs.length - i) * 1000);
items.push({ type: 'log', item: log, timestamp: ts });
});
return items.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
};
export const transformWorkflowMessage = async (msg: any, request: any): Promise<any> => {
let docs: any[] = [];
if (msg.documents?.length > 0) {
// Try to get filenames from the documents, but if missing, fetch from API
const needsApiCall = msg.documents.some((d: any) => !d.filename && !d.fileName && !d.name);
if (needsApiCall) {
// If any document is missing filename, fetch details from API for all
const promises = msg.documents.map(async (d: any) => {
try {
const fileId = d.fileId || d.id;
const res = await request({ url: `/api/workflows/files/${fileId}/preview`, method: 'get' });
const fullName = res.name || res.fileName || d.filename || d.fileName || d.name || `File_${fileId}`;
const lastDotIndex = fullName.lastIndexOf('.');
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullName.length - 1;
return {
id: d.id || d.fileId,
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
name: hasExtension ? fullName.substring(0, lastDotIndex) : fullName,
ext: res.extension || res.ext || (hasExtension ? fullName.substring(lastDotIndex + 1) : ''),
type: d.mimeType || res.mimeType || res.type || 'application/octet-stream',
size: d.fileSize || res.size || 0,
downloadUrl: `/api/workflows/files/${d.fileId}/download`
};
} catch {
// Fallback if API call fails
const fullFilename = d.filename || d.fileName || d.name || `File_${d.id || d.fileId || 'unknown'}`;
const lastDotIndex = fullFilename.lastIndexOf('.');
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullFilename.length - 1;
return {
id: d.id || d.fileId,
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
name: hasExtension ? fullFilename.substring(0, lastDotIndex) : fullFilename,
ext: hasExtension ? fullFilename.substring(lastDotIndex + 1) : '',
type: d.mimeType || 'application/octet-stream',
size: d.fileSize || 0,
downloadUrl: `/api/workflows/files/${d.fileId}/download`
};
}
});
docs = await Promise.all(promises);
} else {
// All documents have filenames, process them directly
docs = msg.documents.map((d: any) => {
const fullFilename = d.filename || d.fileName || d.name || `File_${d.id || d.fileId || 'unknown'}`;
const lastDotIndex = fullFilename.lastIndexOf('.');
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullFilename.length - 1;
return {
id: d.id || d.fileId,
fileId: typeof d.fileId === 'string' ? parseInt(d.fileId) : d.fileId,
name: hasExtension ? fullFilename.substring(0, lastDotIndex) : fullFilename,
ext: hasExtension ? fullFilename.substring(lastDotIndex + 1) : '',
type: d.mimeType,
size: d.fileSize,
downloadUrl: `/api/workflows/files/${d.fileId}/download`
};
});
}
} else if (msg.fileIds?.length > 0) {
const promises = msg.fileIds.map(async (id: number) => {
try {
const res = await request({ url: `/api/workflows/files/${id}/preview`, method: 'get' });
const fullName = res.name || res.fileName || `File_${id}`;
const lastDotIndex = fullName.lastIndexOf('.');
const hasExtension = lastDotIndex > 0 && lastDotIndex < fullName.length - 1;
return {
id: id.toString(),
fileId: id,
name: hasExtension ? fullName.substring(0, lastDotIndex) : fullName,
ext: res.extension || res.ext || (hasExtension ? fullName.substring(lastDotIndex + 1) : ''),
type: res.mimeType || res.type || 'application/octet-stream',
size: res.size || 0,
downloadUrl: res.downloadUrl || res.url
};
} catch {
return { id: id.toString(), fileId: id, name: `File_${id}`, ext: '', type: 'application/octet-stream', size: 0 };
}
});
docs = await Promise.all(promises);
}
return {
id: msg.id,
role: msg.role,
agentName: msg.role === 'user' ? 'You' : 'Assistant',
content: msg.message || msg.content || msg.text || msg.body || '',
timestamp: msg.publishedAt
? (typeof msg.publishedAt === 'number' ? new Date(msg.publishedAt * 1000).toISOString() : msg.publishedAt)
: msg.timestamp,
documents: docs
};
};

View file

@ -1,220 +0,0 @@
// Simplified types - everything in one place
export interface Prompt {
id: string;
mandateId: string;
name: string;
content: string;
createdAt?: string;
isShared?: boolean;
}
export interface DashboardChatProps {
workflowState: WorkflowState;
workflowActions: WorkflowActions;
}
export interface DashboardChatAreaProps {
workflowState: WorkflowState;
workflowActions: WorkflowActions;
}
// Workflow interfaces - Updated to match full API response
export interface WorkflowStats {
id: string;
processingTime: number;
tokenCount: number;
bytesSent: number;
bytesReceived: number;
successRate: number;
errorCount: number;
}
export interface WorkflowDocument {
id: string;
fileId: string;
filename: string;
fileSize: number;
mimeType: string;
}
export interface WorkflowMessageStats extends WorkflowStats {}
export interface WorkflowMessage {
id: string;
workflowId: string;
parentMessageId?: string;
documents?: WorkflowDocument[];
documentsLabel?: string;
message: string;
content?: string; // For backward compatibility
role: 'user' | 'assistant' | 'system';
status: string;
sequenceNr: number;
publishedAt: number; // UTC timestamp in seconds (float from backend)
timestamp?: string; // For backward compatibility
fileIds?: string[]; // For backward compatibility
stats?: WorkflowMessageStats;
success: boolean;
actionId?: string;
actionMethod?: string;
actionName?: string;
}
export interface WorkflowLog {
id: string;
workflowId: string;
message: string;
type: string;
timestamp: string;
status: string;
progress: number;
performance: any;
level?: 'info' | 'warning' | 'error' | 'debug';
source?: string;
details?: any;
}
export interface WorkflowAction {
id: string;
execMethod: string;
execAction: string;
execParameters: any;
execResultLabel: string;
expectedDocumentFormats?: any[];
status: 'pending' | 'running' | 'completed' | 'failed';
error?: string;
retryCount: number;
retryMax: number;
processingTime: number;
timestamp: string;
result?: string;
resultDocuments?: WorkflowDocument[];
}
export interface WorkflowTask {
id: string;
workflowId: string;
userInput: string;
status: 'pending' | 'running' | 'completed' | 'failed';
error?: string;
startedAt: string;
finishedAt?: string;
actionList: WorkflowAction[];
retryCount: number;
retryMax: number;
rollbackOnFailure: boolean;
dependencies: string[];
feedback?: string;
processingTime: number;
resultLabels: any;
}
export interface Workflow {
id: string;
mandateId: string;
status: string;
name?: string;
currentRound: number;
lastActivity: number; // UTC timestamp in seconds (float from backend)
startedAt: number; // UTC timestamp in seconds (float from backend)
logs?: WorkflowLog[];
messages?: WorkflowMessage[];
stats?: WorkflowStats;
tasks?: WorkflowTask[];
}
export interface WorkflowState {
currentWorkflowId: string | null;
workflow: Workflow | null;
messages: WorkflowMessage[];
pendingMessages: WorkflowMessage[]; // Messages sent but not yet confirmed by backend
logs: WorkflowLog[];
isLoading: boolean;
error: string | null;
selectedPrompt: Prompt | null;
}
export interface WorkflowActions {
loadWorkflow: (workflowId: string) => void;
startNewWorkflow: (prompt: string, fileIds?: string[]) => Promise<string | null>;
continueWorkflow: (prompt: string, fileIds?: string[]) => Promise<boolean>;
stopWorkflow: () => Promise<boolean>;
clearWorkflow: () => void;
selectPrompt: (prompt: Prompt | null) => void;
clearPrompt: () => void;
}
export interface FileInfo {
id: string;
name: string;
mimeType: string;
size?: number;
creationDate?: string;
downloadUrl?: string;
fileData?: File;
objectUrl?: string;
}
// Add old interfaces for compatibility
export interface Document {
id?: string;
fileId?: number;
name: string;
url?: string;
type?: string;
size?: number;
downloadUrl?: string;
ext?: string;
}
export interface Message {
id?: string;
role: 'user' | 'assistant' | 'system';
agentName: string;
content: string;
timestamp?: string;
documents?: Document[];
}
export interface InputAreaProps {
workflowState: WorkflowState;
workflowActions: WorkflowActions;
attachedFiles?: AttachedFile[];
onAttachedFilesChange?: (files: AttachedFile[]) => void;
}
export interface AttachedFile {
id: string;
name: string;
size: number;
type: string;
fileData?: File;
objectUrl?: string;
}
export interface ConnectedFilesProps {
onFileSelect?: (file: FileInfo) => void;
selectedFile?: FileInfo | null;
attachedFiles?: AttachedFile[];
onRemoveFile?: (fileId: string) => void;
}
export interface MessageListProps {
workflowState: WorkflowState;
}
// Progress bar interfaces
export interface WorkflowProgress {
current: number;
total: number;
percentage: number;
isLoading: boolean;
taskBreakdown?: string;
}
// Timeline item for merged messages and logs
export interface TimelineItem {
type: 'message' | 'log';
item: any;
timestamp: Date;
}

View file

@ -1,2 +0,0 @@
export { default } from './DashboardChatArea';
export type { DashboardChatAreaProps } from './dashboardChatAreaTypes';

View file

@ -1,279 +0,0 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useWorkflow, useWorkflowStatus, useWorkflowMessages, useWorkflowLogs, useWorkflowOperations, StartWorkflowRequest } from '../../../hooks/useWorkflows';
import { WorkflowState, WorkflowActions } from './dashboardChatAreaTypes';
export function useWorkflowManager(initialWorkflowId?: string | null): [WorkflowState, WorkflowActions] {
// Core state
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(initialWorkflowId || null);
const [isPolling, setIsPolling] = useState(false);
const [pendingMessages, setPendingMessages] = useState<any[]>([]);
const [sentUserMessages, setSentUserMessages] = useState<Set<string>>(new Set()); // Track sent user messages
const [selectedPrompt, setSelectedPrompt] = useState<any | null>(null); // Selected prompt state
const pollingIntervalRef = useRef<number | null>(null);
// Hook-based data fetching
const { workflow, loading: workflowLoading, error: workflowError } = useWorkflow(currentWorkflowId);
const { status: workflowStatus, loading: statusLoading, error: statusError, refetch: refetchStatus } = useWorkflowStatus(currentWorkflowId);
const { messages, loading: messagesLoading, error: messagesError, refetch: refetchMessages } = useWorkflowMessages(currentWorkflowId);
const { logs, loading: logsLoading, error: logsError, refetch: refetchLogs } = useWorkflowLogs(currentWorkflowId);
const { startWorkflow, stopWorkflow: stopWorkflowRequest } = useWorkflowOperations();
// Use status for real-time updates, fallback to workflow for initial data
const currentWorkflow = workflowStatus || workflow;
// Helper to create optimistic user message
const createOptimisticMessage = useCallback((prompt: string, fileIds: string[] = []) => {
// Use UTC timestamp in seconds (float) to match backend expectation
const timestamp = Math.floor(Date.now() / 1000);
return {
id: `temp-${Date.now()}`,
workflowId: currentWorkflowId || 'pending',
message: prompt,
role: 'user' as const,
status: 'pending',
sequenceNr: 0,
publishedAt: timestamp,
success: true,
fileIds: fileIds.length > 0 ? fileIds : undefined
};
}, [currentWorkflowId]);
// Combined loading and error states
const isLoading = workflowLoading || statusLoading || messagesLoading || logsLoading;
const error = workflowError || statusError || messagesError || logsError;
// Filter messages based on workflow state and pending messages
const filteredMessages = useMemo(() => {
if (!messages) return [];
// For completed/stopped workflows, always show all messages (including user messages from backend)
const isWorkflowComplete = currentWorkflow && ['completed', 'stopped', 'failed', 'error'].includes(currentWorkflow.status);
// If workflow is complete OR we have no pending messages and no tracked sent messages,
// show all messages (this covers historical workflows and completed workflows)
if (isWorkflowComplete || (pendingMessages.length === 0 && sentUserMessages.size === 0)) {
return messages;
}
// For active workflows with pending messages, filter out user messages from backend
// to prevent duplicates with optimistic messages
if (pendingMessages.length > 0 || sentUserMessages.size > 0) {
return messages.filter(msg => msg.role !== 'user');
}
// Default: show all messages
return messages;
}, [messages, sentUserMessages, pendingMessages, currentWorkflow]);
// Auto-polling for active workflows and message updates
useEffect(() => {
if (isPolling && currentWorkflowId) {
pollingIntervalRef.current = window.setInterval(() => {
refetchStatus();
if (currentWorkflow) {
const isActive = ['running', 'processing', 'started'].includes(currentWorkflow.status);
if (isActive) {
refetchMessages();
refetchLogs();
}
}
}, 2000);
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [isPolling, currentWorkflowId, currentWorkflow?.status, refetchStatus, refetchMessages, refetchLogs]);
// Actions
const loadWorkflow = useCallback(async (workflowId: string) => {
// Immediately clear pending state when loading a different workflow
if (currentWorkflowId !== workflowId) {
setPendingMessages([]);
setSentUserMessages(new Set());
}
setCurrentWorkflowId(workflowId);
}, [currentWorkflowId]);
const startNewWorkflow = useCallback(async (prompt: string, fileIds: string[] = []): Promise<string | null> => {
// Add optimistic message immediately
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
setPendingMessages(prev => [...prev, optimisticMessage]);
// Track this message as sent
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
const workflowData: StartWorkflowRequest = {
prompt,
listFileId: fileIds
};
const result = await startWorkflow(workflowData);
if (result.success && result.data) {
const newWorkflowId = result.data.id;
setCurrentWorkflowId(newWorkflowId);
setIsPolling(true);
setTimeout(() => {
refetchMessages();
refetchLogs();
}, 500);
return newWorkflowId;
} else {
// Remove optimistic message and sent tracking on failure
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
setSentUserMessages(prev => {
const newSet = new Set(prev);
newSet.delete(prompt.trim());
return newSet;
});
}
return null;
}, [startWorkflow, refetchMessages, createOptimisticMessage]);
const continueWorkflow = useCallback(async (prompt: string, fileIds: string[] = []): Promise<boolean> => {
if (!currentWorkflowId) return false;
// Add optimistic message immediately
const optimisticMessage = createOptimisticMessage(prompt, fileIds);
setPendingMessages(prev => [...prev, optimisticMessage]);
// Track this message as sent
setSentUserMessages(prev => new Set(prev).add(prompt.trim()));
const workflowData: StartWorkflowRequest = {
prompt,
listFileId: fileIds
};
const result = await startWorkflow(workflowData, currentWorkflowId);
if (result.success) {
setIsPolling(true);
setTimeout(() => {
refetchMessages();
refetchLogs();
}, 500);
return true;
} else {
// Remove optimistic message and sent tracking on failure
setPendingMessages(prev => prev.filter(msg => msg.id !== optimisticMessage.id));
setSentUserMessages(prev => {
const newSet = new Set(prev);
newSet.delete(prompt.trim());
return newSet;
});
}
return false;
}, [currentWorkflowId, startWorkflow, refetchMessages, refetchLogs, createOptimisticMessage]);
const stopWorkflow = useCallback(async (): Promise<boolean> => {
if (!currentWorkflowId) return false;
const result = await stopWorkflowRequest(currentWorkflowId);
if (result) {
// Immediately refresh status to reflect the stopped state
setTimeout(() => {
refetchStatus();
refetchMessages();
refetchLogs();
}, 500);
return true;
}
return false;
}, [currentWorkflowId, stopWorkflowRequest, refetchStatus, refetchMessages, refetchLogs]);
const clearWorkflow = useCallback(() => {
setCurrentWorkflowId(null);
setIsPolling(false);
setPendingMessages([]);
setSentUserMessages(new Set());
}, []);
const selectPrompt = useCallback((prompt: any | null) => {
setSelectedPrompt(prompt);
}, []);
const clearPrompt = useCallback(() => {
setSelectedPrompt(null);
}, []);
// Only clear pending messages when workflow changes to a different ID
// (not when creating a new workflow)
const previousWorkflowId = useRef(currentWorkflowId);
useEffect(() => {
const prev = previousWorkflowId.current;
const current = currentWorkflowId;
// If we're switching between different existing workflows, clear state
if (prev && current && prev !== current) {
setPendingMessages([]);
setSentUserMessages(new Set());
}
// If we're going from a workflow to no workflow, clear state
else if (prev && !current) {
setPendingMessages([]);
setSentUserMessages(new Set());
}
// If we're going from no workflow to a workflow (new workflow creation), keep pending messages
previousWorkflowId.current = current;
}, [currentWorkflowId]);
// Sync with external workflow ID changes
useEffect(() => {
if (initialWorkflowId !== currentWorkflowId) {
if (initialWorkflowId) {
loadWorkflow(initialWorkflowId);
} else {
setCurrentWorkflowId(null);
setIsPolling(false);
}
}
}, [initialWorkflowId]);
// Auto-enable polling only for active workflows
useEffect(() => {
if (currentWorkflowId && currentWorkflow) {
const isActive = ['running', 'processing', 'started'].includes(currentWorkflow.status);
setIsPolling(isActive);
// Clear pending messages and sent user tracking when workflow completes
// This allows all backend messages to show for completed workflows
if (!isActive && (pendingMessages.length > 0 || sentUserMessages.size > 0)) {
console.log('🏁 Workflow completed, clearing pending messages and sent user tracking');
setPendingMessages([]);
setSentUserMessages(new Set());
}
} else {
setIsPolling(false);
}
}, [currentWorkflowId, currentWorkflow?.status, pendingMessages.length, sentUserMessages.size]);
const state: WorkflowState = {
currentWorkflowId,
workflow: currentWorkflow,
messages: filteredMessages,
pendingMessages,
logs: logs || [],
isLoading,
error,
selectedPrompt
};
const actions: WorkflowActions = useMemo(() => ({
loadWorkflow,
startNewWorkflow,
continueWorkflow,
stopWorkflow,
clearWorkflow,
selectPrompt,
clearPrompt
}), [loadWorkflow, startNewWorkflow, continueWorkflow, stopWorkflow, clearWorkflow, selectPrompt, clearPrompt]);
return [state, actions];
}

View file

@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
import { Popup, PopupAction } from '../ui/Popup/Popup';
import { useLanguage } from '../../contexts/LanguageContext';
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useFileOperations } from '../../hooks/useFiles';
import {
JsonRenderer,

View file

@ -1,4 +1,4 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
interface ErrorRendererProps {

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
interface JsonRendererProps {

View file

@ -1,4 +1,4 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
export function LoadingRenderer() {

View file

@ -1,5 +1,5 @@
import { IoIosWarning } from 'react-icons/io';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
interface PdfRendererProps {

View file

@ -1,4 +1,4 @@
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../FilePreview.module.css';
interface UnsupportedRendererProps {

View file

@ -170,6 +170,33 @@
background: var(--color-secondary-hover);
}
.actionButton.connect {
background: var(--color-secondary);
color: white;
}
.actionButton.connect:hover {
background: var(--color-secondary-hover);
}
.actionButton.refresh {
background: var(--color-secondary);
color: white;
}
.actionButton.refresh:hover {
background: var(--color-secondary-hover);
}
.actionButton.remove {
background: var(--color-secondary);
color: white;
}
.actionButton.remove:hover {
background: var(--color-secondary-hover);
}
/* Responsive Design */
@media (max-width: 768px) {
.actionButtons {
@ -232,4 +259,20 @@
.actionButton.copy:hover {
background: var(--color-secondary-hover);
}
.actionButton.connect {
background: var(--color-secondary);
}
.actionButton.connect:hover {
background: var(--color-secondary-hover);
}
.actionButton.refresh {
background: var(--color-secondary);
}
.actionButton.refresh:hover {
background: var(--color-secondary-hover);
}
}

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { IoIosLink } from 'react-icons/io';
import { IoIosRefresh } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface ConnectActionButtonProps<T = any> {
row: T;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
connectTitle?: string;
refreshTitle?: string;
hookData: any; // REQUIRED: Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
statusField?: string; // Field name for the status field
operationName?: string; // Name of the connect operation in hookData
loadingStateName?: string; // Name of the loading state in hookData
}
export function ConnectActionButton<T = any>({
row,
disabled = false,
loading = false,
className = '',
title,
connectTitle,
refreshTitle,
hookData,
idField = 'id',
statusField = 'status',
operationName = 'connectWithPopup',
loadingStateName = 'isConnecting'
}: ConnectActionButtonProps<T>) {
const { t } = useLanguage();
const [isProcessing, setIsProcessing] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
// Validate that hookData is provided with required operations
if (!hookData) {
throw new Error('ConnectActionButton requires hookData to be provided');
}
// Get the connection data from the row
const connectionStatus = (row as any)[statusField];
const connectionId = (row as any)[idField];
const isActive = connectionStatus === 'active';
// Extract operations from hookData
const handleConnect = hookData[operationName];
const refetch = hookData.refetch;
const loadingState = hookData[loadingStateName];
// Validate required operations exist
if (!handleConnect) {
throw new Error(`ConnectActionButton requires hookData.${operationName} to be defined`);
}
if (!refetch) {
throw new Error('ConnectActionButton requires hookData.refetch to be defined');
}
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !isProcessing) {
setIsProcessing(true);
try {
// Always use the connect operation for both active and inactive connections
// The backend will handle refreshing tokens for active connections
if (handleConnect) {
await handleConnect(connectionId);
}
// Refetch to update the connection status
if (refetch) {
await refetch();
}
} catch (error: any) {
console.error('Connection operation failed:', error);
} finally {
setIsProcessing(false);
}
}
};
// Determine button title and icon based on connection status
const defaultTitle = isActive
? (refreshTitle || t('connections.action.refresh', 'Refresh'))
: (connectTitle || t('connections.action.connect', 'Connect'));
const buttonTitle = title || defaultTitle;
const buttonIcon = isActive ? <IoIosRefresh /> : <IoIosLink />;
// Check if this specific connection is being processed
const isLoadingState = loadingState === true || isProcessing;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${isActive ? styles.refresh : styles.connect} ${isLoadingState ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || loading || isLoadingState}
>
<span className={styles.actionIcon}>
{isLoadingState ? <IoIosRefresh /> : buttonIcon}
</span>
</button>
);
}
export default ConnectActionButton;

View file

@ -0,0 +1,3 @@
export { default as ConnectActionButton } from './ConnectActionButton';
export type { ConnectActionButtonProps } from './ConnectActionButton';

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { IoCopy } from 'react-icons/io5';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface CopyActionButtonProps<T = any> {
@ -11,13 +11,8 @@ export interface CopyActionButtonProps<T = any> {
className?: string;
title?: string;
isCopying?: boolean;
hookData?: any; // Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for display name
contentField?: string; // Field name for content
loadingStateName?: string; // Name of the loading state in hookData
operationName?: string; // Name of the operation function in hookData
// Field mappings for clipboard copy
contentField?: string; // Field name for content to copy to clipboard (default: 'content')
}
export function CopyActionButton<T = any>({
@ -28,11 +23,7 @@ export function CopyActionButton<T = any>({
className = '',
title,
isCopying = false,
hookData,
nameField = 'name',
contentField = 'content',
loadingStateName = 'creatingPrompt',
operationName = 'handlePromptCreate'
contentField = 'content'
}: CopyActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
@ -47,48 +38,33 @@ export function CopyActionButton<T = any>({
if (!isDisabled && !loading && !isCopying && !internalLoading) {
setInternalLoading(true);
try {
// If operationName is provided and hookData is available, use the hook function
if (operationName && hookData && hookData[operationName]) {
// Extract data from row for creating a copy
const copyData = {
name: `${(row as any)[nameField]} (Copy)`,
content: (row as any)[contentField],
mandateId: (row as any).mandateId
};
const result = await hookData[operationName](copyData);
if (result.success) {
// Show copied feedback
setShowCopiedFeedback(true);
setTimeout(() => setShowCopiedFeedback(false), 2000);
// Refetch to update the list
if (hookData.refetch) {
await hookData.refetch();
}
}
} else if (onCopy) {
// Fallback to the provided onCopy function
if (onCopy) {
// Use custom copy function if provided
await onCopy(row);
setShowCopiedFeedback(true);
setTimeout(() => setShowCopiedFeedback(false), 2000);
} else {
console.error('No copy function available');
// Default behavior: copy the specified field content to clipboard
const contentToCopy = (row as any)[contentField];
if (contentToCopy !== undefined && contentToCopy !== null) {
await navigator.clipboard.writeText(String(contentToCopy));
} else {
console.warn(`Field "${contentField}" not found or is empty in row:`, row);
}
}
// Show copied feedback
setShowCopiedFeedback(true);
setTimeout(() => setShowCopiedFeedback(false), 2000);
} catch (error) {
console.error('Copy failed:', error);
console.error('Copy to clipboard failed:', error);
} finally {
setInternalLoading(false);
}
}
};
const buttonTitle = title || t('prompts.action.copy', 'Copy');
// Use hookData copying state if available, otherwise use passed isCopying
const loadingState = hookData?.[loadingStateName];
const actualIsCopying = (typeof loadingState === 'boolean' && loadingState) || isCopying;
const isLoading = loading || actualIsCopying || internalLoading;
const buttonTitle = title || t('prompts.action.copy', 'Copy to Clipboard');
const isLoading = loading || isCopying || internalLoading;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { IoIosTrash, IoIosCheckmark, IoIosClose } from 'react-icons/io';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface DeleteActionButtonProps<T = any> {
@ -56,6 +56,7 @@ export function DeleteActionButton<T = any>({
const refetch = hookData.refetch;
const loadingState = hookData[loadingStateName];
// Validate required operations exist
if (!handleDelete) {
throw new Error(`DeleteActionButton requires hookData.${operationName} to be defined`);
@ -117,12 +118,11 @@ export function DeleteActionButton<T = any>({
const success = await handleDelete(itemId);
if (success) {
// If we used optimistic removal, delay refetch to ensure server has synced
// If we used optimistic removal, don't refetch immediately
// The item is already removed from UI, and refetch might bring it back
if (removeOptimistically) {
// Delay refetch by 500ms to give server time to fully process deletion
setTimeout(() => {
refetch();
}, 500);
// Only refetch if there was an error or if we need to sync other changes
// For now, we trust the optimistic removal worked
} else {
// No optimistic removal, refetch immediately
refetch();

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { IoIosDownload } from 'react-icons/io';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface DownloadActionButtonProps<T = any> {

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { MdModeEdit } from 'react-icons/md';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { Popup, EditForm } from '../../../ui/Popup';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { Popup, EditForm } from '../../../UiComponents/Popup';
import styles from '../ActionButton.module.css';
export interface EditActionButtonProps<T = any> {
@ -121,32 +121,50 @@ export function EditActionButton<T = any>({
}
});
// Check if optimistic update is available
const updateOptimistically = hookData.updateOptimistically || hookData.updateFileOptimistically;
// Validate required operation exists
if (!hookData[operationName]) {
throw new Error(`EditActionButton requires hookData.${operationName} to be defined`);
}
if (!hookData.refetch) {
throw new Error('EditActionButton requires hookData.refetch to be defined');
// Optimistically update the UI immediately
if (updateOptimistically) {
updateOptimistically(itemId, updateData);
}
// Use hookData operation to update
// Close popup and reset state immediately for better UX
setIsPopupOpen(false);
setEditData(null);
// Use hookData operation to update in the background
const result = await hookData[operationName](itemId, updateData, editData);
const success = result?.success || result === true;
if (success) {
// Close popup and reset state
setIsPopupOpen(false);
setEditData(null);
// Trigger refetch to sync with backend
await hookData.refetch();
// If we used optimistic update, don't refetch to avoid overwriting our changes
if (updateOptimistically) {
// Trust the optimistic update worked
} else {
// No optimistic update, refetch to sync with backend
if (hookData.refetch) {
await hookData.refetch();
}
}
} else {
// If update failed, refetch to restore original state
if (hookData.refetch) {
await hookData.refetch();
}
console.error('Failed to update item:', itemId);
// TODO: Show error message to user
}
} catch (error) {
// If update failed, refetch to restore original state
if (hookData.refetch) {
await hookData.refetch();
}
console.error('Failed to update item:', error);
// TODO: Show error message to user
} finally {

View file

@ -0,0 +1,88 @@
import React from 'react';
import { IoIosPlay } from 'react-icons/io';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowSelection } from '../../../../contexts/WorkflowSelectionContext';
import styles from '../ActionButton.module.css';
export interface PlayActionButtonProps<T = any> {
row: T;
onPlay?: (row: T) => Promise<void> | void;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
hookData?: any; // Contains all hook data including operations
// Field mappings
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for display name
// Navigation
navigateTo?: string; // Path to navigate to after selection (default: 'start/dashboard')
}
export function PlayActionButton<T = any>({
row,
onPlay,
disabled = false,
loading = false,
className = '',
title,
hookData,
idField = 'id',
nameField = 'name',
navigateTo = 'start/dashboard'
}: PlayActionButtonProps<T>) {
const { t } = useLanguage();
const navigate = useNavigate();
const { selectWorkflow } = useWorkflowSelection();
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading) {
try {
// Get workflow ID from row
const workflowId = (row as any)[idField];
if (!workflowId) {
console.error('Workflow ID not found in row');
return;
}
// Call the onPlay callback if provided
if (onPlay) {
await onPlay(row);
}
// Select the workflow in context
selectWorkflow(workflowId);
// Navigate to dashboard (or specified path)
navigate(`/${navigateTo}`);
} catch (error: any) {
console.error('Error playing workflow:', error);
}
}
};
const buttonTitle = title || t('workflows.action.play', 'Play');
const isLoading = loading;
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.play} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || isLoading}
>
<span className={styles.actionIcon}>
{isLoading ? '⏳' : <IoIosPlay />}
</span>
</button>
);
}
export default PlayActionButton;

View file

@ -0,0 +1,2 @@
export { PlayActionButton } from './PlayActionButton';
export type { PlayActionButtonProps } from './PlayActionButton';

View file

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { IoIosCloseCircle } from 'react-icons/io';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../ActionButton.module.css';
export interface RemoveActionButtonProps<T = any> {
row: T;
onRemove: (row: T) => Promise<void> | void;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
idField?: string; // Field name for the unique identifier
loadingStateName?: string; // Name of the loading state in hookData
hookData?: any; // Optional hook data for loading state
}
export function RemoveActionButton<T = any>({
row,
onRemove,
disabled = false,
loading = false,
className = '',
title,
idField = 'id',
loadingStateName = 'removingItems',
hookData
}: RemoveActionButtonProps<T>) {
const { t } = useLanguage();
const [internalLoading, setInternalLoading] = useState(false);
// Extract disabled state and tooltip message
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && !internalLoading) {
setInternalLoading(true);
try {
await onRemove(row);
} finally {
setInternalLoading(false);
}
}
};
const buttonTitle = title || t('files.action.remove', 'Remove from workflow');
// Use hookData removing state if available, otherwise use passed loading
const loadingState = hookData?.[loadingStateName];
const itemId = (row as any)[idField];
const actualIsRemoving = loadingState?.has?.(itemId) || false;
const isLoading = loading || actualIsRemoving || internalLoading;
// Determine the final button title (tooltip)
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<button
onClick={handleClick}
className={`${styles.actionButton} ${styles.remove} ${isLoading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || isLoading}
>
<span className={styles.actionIcon}>
{isLoading ? '⏳' : <IoIosCloseCircle />}
</span>
</button>
);
}
export default RemoveActionButton;

View file

@ -0,0 +1,6 @@
export { default as RemoveActionButton } from './RemoveActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton';

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { IoIosEye } from 'react-icons/io';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { FilePreview } from '../../../FilePreview/FilePreview';
import styles from '../ActionButton.module.css';

View file

@ -4,6 +4,9 @@ export { DeleteActionButton } from './DeleteActionButton';
export { DownloadActionButton } from './DownloadActionButton';
export { ViewActionButton } from './ViewActionButton';
export { CopyActionButton } from './CopyActionButton';
export { ConnectActionButton } from './ConnectActionButton';
export { PlayActionButton } from './PlayActionButton';
export { RemoveActionButton } from './RemoveActionButton';
// Action Button Types
export type { EditActionButtonProps } from './EditActionButton';
@ -11,3 +14,6 @@ export type { DeleteActionButtonProps } from './DeleteActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';
export type { ViewActionButtonProps } from './ViewActionButton';
export type { CopyActionButtonProps } from './CopyActionButton';
export type { ConnectActionButtonProps } from './ConnectActionButton';
export type { PlayActionButtonProps } from './PlayActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton';

View file

@ -1,14 +1,16 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './FormGenerator.module.css';
import {
EditActionButton,
DeleteActionButton,
DownloadActionButton,
ViewActionButton,
CopyActionButton
CopyActionButton,
ConnectActionButton,
PlayActionButton
} from './ActionButtons';
import { Button } from '../ui/Button';
import { Button } from '../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io";
import { FaTrash } from "react-icons/fa";
@ -47,7 +49,7 @@ export interface FormGeneratorProps<T = any> {
isRowSelectable?: (row: T) => boolean;
loading?: boolean;
actionButtons?: {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
disabled?: (row: T) => boolean | { disabled: boolean; message?: string };
loading?: (row: T) => boolean;
@ -60,8 +62,11 @@ export interface FormGeneratorProps<T = any> {
nameField?: string; // Field name for display name
typeField?: string; // Field name for type/mime type
contentField?: string; // Field name for content (used by copy button)
statusField?: string; // Field name for status (used by connect button)
authorityField?: string; // Field name for authority (msft/google) (used by connect button)
// Operation and loading state names
operationName?: string; // Name of the operation function in hookData
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
loadingStateName?: string; // Name of the loading state in hookData
// Edit configuration (for edit buttons)
editFields?: Array<{
@ -201,8 +206,36 @@ export function FormGenerator<T extends Record<string, any>>({
} else if (column?.type === 'number') {
return Number(value) === Number(filterValue);
} else if (column?.type === 'date') {
// Convert row value to DD.MM.YYYY format for comparison
const rowDate = new Date(value);
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
let rowDate: Date;
if (typeof value === 'number') {
// If it's a number, check if it's in seconds (typical Unix timestamp range)
if (value < 10000000000) { // Less than year 2286 in seconds
rowDate = new Date(value * 1000); // Convert seconds to milliseconds
} else {
rowDate = new Date(value); // Already in milliseconds
}
} else if (typeof value === 'string') {
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
if (value.includes('T') || value.includes('-') || value.includes(':')) {
rowDate = new Date(value); // Parse as date string (ISO or other formats)
} else {
// Try to parse as number (Unix timestamp as string)
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (numValue < 10000000000) { // Less than year 2286 in seconds
rowDate = new Date(numValue * 1000); // Convert seconds to milliseconds
} else {
rowDate = new Date(numValue); // Already in milliseconds
}
} else {
rowDate = new Date(value); // Fallback: try parsing as date string
}
}
} else {
rowDate = new Date(value);
}
const rowFormatted = `${rowDate.getDate().toString().padStart(2, '0')}.${(rowDate.getMonth() + 1).toString().padStart(2, '0')}.${rowDate.getFullYear()}`;
// Check if filter value is complete (DD.MM.YYYY)
@ -416,7 +449,37 @@ export function FormGenerator<T extends Record<string, any>>({
switch (column.type) {
case 'date':
try {
const date = new Date(value);
// Handle Unix timestamps in seconds (backend format) by converting to milliseconds
let date: Date;
if (typeof value === 'number') {
// If it's a number, check if it's in seconds (typical Unix timestamp range)
// Unix timestamps in seconds are typically much smaller than milliseconds
if (value < 10000000000) { // Less than year 2286 in seconds
date = new Date(value * 1000); // Convert seconds to milliseconds
} else {
date = new Date(value); // Already in milliseconds
}
} else if (typeof value === 'string') {
// Check if it's an ISO date string or date string (contains 'T', '-', or ':')
if (value.includes('T') || value.includes('-') || value.includes(':')) {
date = new Date(value); // Parse as date string (ISO or other formats)
} else {
// Try to parse as number (Unix timestamp as string)
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
if (numValue < 10000000000) { // Less than year 2286 in seconds
date = new Date(numValue * 1000); // Convert seconds to milliseconds
} else {
date = new Date(numValue); // Already in milliseconds
}
} else {
date = new Date(value); // Fallback: try parsing as date string
}
}
} else {
date = new Date(value);
}
if (isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@ -787,7 +850,10 @@ export function FormGenerator<T extends Record<string, any>>({
nameField: actionButton.nameField ?? 'name',
typeField: actionButton.typeField ?? 'type',
contentField: actionButton.contentField ?? 'content',
statusField: actionButton.statusField ?? 'status',
authorityField: actionButton.authorityField ?? 'authority',
operationName: actionButton.operationName,
refreshOperationName: actionButton.refreshOperationName,
loadingStateName: actionButton.loadingStateName
};
@ -807,7 +873,11 @@ export function FormGenerator<T extends Record<string, any>>({
case 'view':
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy':
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} hookData={hookData} operationName={actionButton.operationName} contentField={actionButton.contentField} />;
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
case 'connect':
return <ConnectActionButton key={actionIndex} {...baseProps} hookData={hookData} />;
case 'play':
return <PlayActionButton key={actionIndex} {...baseProps} onPlay={actionButton.onAction} hookData={hookData} navigateTo={actionButton.navigateTo} />;
default:
return null;
}

View file

@ -1,9 +1,9 @@
import { FormGenerator } from '../FormGenerator/FormGenerator';
import { Popup } from '../ui/Popup/Popup';
import { EditForm, EditFieldConfig } from '../ui/Popup/EditForm';
import { Popup } from '../UiComponents/Popup/Popup';
import { EditForm, EditFieldConfig } from '../UiComponents/Popup/EditForm';
import { useMitgliederLogic } from './mitgliederLogic';
import { MitgliederTableProps } from './mitgliederTypes';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './MitgliederTable.module.css';
function MitgliederTable({ className = '', showAddUser = false, onAddUserClose }: MitgliederTableProps) {

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { useOrgUsers } from '../../hooks/useUsers';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import type {
MitgliederLogicReturn,

View file

@ -1,76 +0,0 @@
.promptsTable {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.promptsFormGenerator {
flex: 1;
height: 100%;
}
/* Error state styling */
.errorState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--color-error, #dc3545);
background-color: var(--color-error-bg, #f8d7da);
border: 1px solid var(--color-error-border, #f5c6cb);
border-radius: 8px;
margin: 1rem;
}
.retryButton {
padding: 0.5rem 1rem;
background-color: var(--color-primary, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
transition: background-color 0.2s ease;
}
.retryButton:hover {
background-color: var(--color-primary-dark, #0056b3);
}
/* Prompt-specific styling */
.promptName {
font-weight: 500;
color: var(--color-text);
}
.promptContent {
color: var(--color-text);
line-height: 1.4;
max-width: 100%;
word-wrap: break-word;
}
/* Responsive design */
@media (max-width: 768px) {
.promptName {
font-size: 0.9em;
}
.promptContent {
font-size: 0.85em;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.promptName {
color: var(--color-text);
}
.promptContent {
color: var(--color-text);
}
}

View file

@ -1,105 +0,0 @@
import { FormGenerator } from '../FormGenerator/FormGenerator';
import { Popup } from '../ui/Popup/Popup';
import { EditForm } from '../ui/Popup/EditForm';
import { usePromptsLogic } from './promptsLogic';
import { PromptsTableProps, Prompt } from './promptsTypes';
import { useLanguage } from '../../contexts/LanguageContext';
import styles from './PromptsTable.module.css';
// Helper function to determine if a prompt can be selected/deleted
const isPromptSelectable = (prompt: Prompt): boolean => {
// Primary check: If backend explicitly sets _hideDelete, respect that
if (prompt._hideDelete === true) {
return false;
}
// Hardcoded list of the six default system prompt IDs that cannot be selected/deleted
const systemPromptIds = [
"097d34f0-9fcc-4233-bf1e-e64e13818464",
"17546dc6-b792-40e1-9aa1-0fcc4860d0c1",
"17c42519-2bf6-49b4-83f9-3cde7498310c",
"2bb85d1e-4e02-4de8-98ae-0e815267d864",
"93343a5b-49f0-4dbf-9513-2ab5f6938fd8",
"cfb51260-486f-4b42-96fe-ef03f406dcf1"
];
// Check if this prompt is one of the system default prompts
return !systemPromptIds.includes(prompt.id);
};
function PromptsTable({ className = '' }: PromptsTableProps) {
const { t } = useLanguage();
const {
prompts,
loading,
error,
columns,
actions,
handleDeleteSingle,
handleDeleteMultiple,
editModalOpen,
editingPrompt,
editPromptFields,
handleSavePrompt,
handleCancelEdit,
refetch
} = usePromptsLogic();
if (error) {
return (
<div className={styles.errorState}>
<p>{t('prompts.error.loading', 'Error loading prompts:')} {error}</p>
<button onClick={refetch} className={styles.retryButton}>
{t('common.retry', 'Retry')}
</button>
</div>
);
}
return (
<div className={`${styles.promptsTable} ${className}`}>
<FormGenerator
data={prompts}
columns={columns}
title={t('prompts.title', 'Prompts')}
searchable={true}
filterable={true}
sortable={true}
resizable={true}
pagination={true}
pageSize={10}
selectable={true}
isRowSelectable={isPromptSelectable}
loading={loading}
actionButtons={actions}
onDelete={handleDeleteSingle}
onDeleteMultiple={handleDeleteMultiple}
onRefresh={refetch}
className={styles.promptsFormGenerator}
/>
{/* Edit Modal */}
<Popup
isOpen={editModalOpen}
title={t('prompts.modal.edit.title', 'Edit Prompt')}
onClose={handleCancelEdit}
size="large"
>
{editingPrompt && (
<EditForm
data={editingPrompt}
fields={editPromptFields}
onSave={handleSavePrompt}
onCancel={handleCancelEdit}
saveButtonText={t('prompts.modal.edit.save', 'Save Changes')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
)}
</Popup>
</div>
);
}
export default PromptsTable;

View file

@ -1,3 +0,0 @@
export { default as PromptsTable } from './PromptsTable';
export { usePromptsLogic } from './promptsLogic';
export type * from './promptsTypes';

View file

@ -1,315 +0,0 @@
import { useState, useMemo } from 'react';
import { usePrompts, usePromptOperations, Prompt } from '../../hooks/usePrompts';
import { useLanguage } from '../../contexts/LanguageContext';
import type { EditFieldConfig } from '../ui/Popup/EditForm';
// Helper function to determine if a prompt can be deleted
const isPromptDeletable = (prompt: Prompt): boolean => {
// Primary check: If backend explicitly sets _hideDelete, respect that
if (prompt._hideDelete === true) {
return false;
}
// Hardcoded list of the six default system prompt IDs that cannot be deleted
const systemPromptIds = [
"097d34f0-9fcc-4233-bf1e-e64e13818464",
"17546dc6-b792-40e1-9aa1-0fcc4860d0c1",
"17c42519-2bf6-49b4-83f9-3cde7498310c",
"2bb85d1e-4e02-4de8-98ae-0e815267d864",
"93343a5b-49f0-4dbf-9513-2ab5f6938fd8",
"cfb51260-486f-4b42-96fe-ef03f406dcf1"
];
// Check if this prompt is one of the system default prompts
return !systemPromptIds.includes(prompt.id);
};
import type {
PromptsLogicReturn,
PromptActionConfig,
PromptColumnConfig
} from './promptsTypes';
export function usePromptsLogic(): PromptsLogicReturn {
const { prompts, loading, error, refetch } = usePrompts();
const { t } = useLanguage();
const {
handlePromptDelete,
handlePromptUpdate,
deletingPrompts
} = usePromptOperations();
// Edit modal state
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingPrompt, setEditingPrompt] = useState<Prompt | null>(null);
// Configure edit fields for prompt editing
const editPromptFields: EditFieldConfig[] = useMemo(() => [
{
key: 'name',
label: t('prompts.field.name', 'Prompt Name'),
type: 'string',
editable: true,
required: true,
validator: (value: string) => {
if (!value || value.trim() === '') {
return t('prompts.validation.nameRequired', 'Prompt name cannot be empty');
}
if (value.length > 100) {
return t('prompts.validation.nameTooLong', 'Prompt name cannot exceed 100 characters');
}
return null;
}
},
{
key: 'content',
label: t('prompts.field.content', 'Prompt Content'),
type: 'textarea',
editable: true,
required: true,
minRows: 4,
maxRows: 8,
validator: (value: string) => {
if (!value || value.trim() === '') {
return t('prompts.validation.contentRequired', 'Prompt content cannot be empty');
}
if (value.length > 10000) {
return t('prompts.validation.contentTooLong', 'Prompt content cannot exceed 10,000 characters');
}
return null;
}
}
], [t]);
// Configure columns for the prompts table
const columns: PromptColumnConfig[] = useMemo(() => [
{
key: 'name',
label: t('prompts.column.name', 'Name'),
type: 'string',
width: 200,
minWidth: 150,
maxWidth: 300,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined) => (
<span className="promptName">
{value || t('prompts.unnamed', 'Unnamed')}
</span>
)
},
{
key: 'content',
label: t('prompts.column.content', 'Content'),
type: 'string',
width: 400,
minWidth: 200,
maxWidth: 600,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string | undefined) => (
<span className="promptContent" title={value}>
{value && value.length > 100 ? `${value.substring(0, 100)}...` : value || '-'}
</span>
)
}
], [t]);
// Handle prompt actions
const handleDeletePrompt = async (prompt: Prompt) => {
const promptName = prompt.name || prompt.id;
if (window.confirm(t('prompts.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', promptName))) {
const success = await handlePromptDelete(prompt.id);
if (success) {
refetch(); // Refresh the prompts list
}
}
};
// Handle single prompt deletion for bulk delete
const handleDeleteSingle = async (prompt: Prompt) => {
const promptName = prompt.name || prompt.id;
if (window.confirm(t('prompts.delete.confirm', 'Are you sure you want to delete "{name}"?').replace('{name}', promptName))) {
const success = await handlePromptDelete(prompt.id);
if (success) {
refetch(); // Refresh the prompts list
} else {
console.error('Delete failed for prompt:', prompt.id);
}
}
};
// Handle multiple prompt deletion
const handleDeleteMultiple = async (promptsToDelete: Prompt[]) => {
const promptCount = promptsToDelete.length;
if (window.confirm(t('prompts.delete.confirmMultiple', 'Are you sure you want to delete {count} prompts?').replace('{count}', promptCount.toString()))) {
// Start all delete operations simultaneously
const deletePromises = promptsToDelete.map(async (prompt) => {
try {
const success = await handlePromptDelete(prompt.id);
return { promptId: prompt.id, success };
} catch (error) {
console.error('Failed to delete prompt:', prompt.id, error);
return { promptId: prompt.id, success: false };
}
});
// Wait for all deletions to complete
const results = await Promise.all(deletePromises);
// Check if any deletions failed
const failedDeletions = results.filter(result => !result.success);
if (failedDeletions.length > 0) {
console.error('Some prompt deletions failed:', failedDeletions);
}
// Refresh the prompt list regardless of individual failures
refetch();
}
};
// Handle edit prompt
const handleEditPrompt = (prompt: Prompt) => {
setEditingPrompt(prompt);
setEditModalOpen(true);
};
// Handle save prompt
const handleSavePrompt = async (updatedPrompt: Prompt) => {
if (!editingPrompt) return;
try {
// Call API to update prompt - backend requires full Prompt object
const updateData = {
id: editingPrompt.id,
name: updatedPrompt.name,
content: updatedPrompt.content,
mandateId: editingPrompt.mandateId
};
const result = await handlePromptUpdate(editingPrompt.id, updateData);
if (result.success) {
// Close modal
setEditModalOpen(false);
setEditingPrompt(null);
// Refresh prompt list
await refetch();
// Notify other components that prompts have been updated
window.dispatchEvent(new CustomEvent('promptUpdated', {
detail: { promptId: editingPrompt.id, newName: updatedPrompt.name }
}));
} else {
console.error('Failed to update prompt:', result.error);
// TODO: Show error message to user
}
} catch (error) {
console.error('Failed to update prompt:', error);
// TODO: Show error message to user
}
};
// Handle cancel edit
const handleCancelEdit = () => {
setEditModalOpen(false);
setEditingPrompt(null);
};
// Handle copy prompt
const handleCopyPrompt = async (prompt: Prompt) => {
try {
// Use the modern Clipboard API if available
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(prompt.content);
} else {
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea');
textArea.value = prompt.content;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
// Show success feedback
const promptName = prompt.name || t('prompts.unnamed', 'Unnamed');
console.log(`Copied content of "${promptName}" to clipboard`);
// Optional: You could add a toast notification here in the future
// showToast(t('prompts.copy.success', 'Prompt content copied to clipboard'), 'success');
} catch (error) {
console.error('Failed to copy prompt content:', error);
// Optional: You could add a toast notification here in the future
// showToast(t('prompts.copy.error', 'Failed to copy prompt content'), 'error');
}
};
// Configure action buttons
const actions: PromptActionConfig[] = useMemo(() => [
{
type: 'edit',
title: t('prompts.action.edit', 'Edit'),
onAction: (row: Prompt) => {
handleEditPrompt(row);
}
},
{
type: 'copy',
title: t('prompts.action.copy', 'Copy'),
onAction: (row: Prompt) => {
handleCopyPrompt(row);
}
},
{
type: 'delete',
title: (row: Prompt) => {
const isDeletable = isPromptDeletable(row);
return isDeletable
? t('prompts.action.delete', 'Delete')
: t('prompts.action.delete.disabled', 'No permission to delete prompt');
},
disabled: (row: Prompt) => !isPromptDeletable(row)
// onAction is handled by FormGenerator for delete confirmation
},
] as any, [t, deletingPrompts, handleDeletePrompt, handleEditPrompt, handleCopyPrompt]);
return {
// Data
prompts,
loading,
error,
// Actions
handleDeleteSingle,
handleDeleteMultiple,
handleEditPrompt,
handleCopyPrompt,
// Refetch function
refetch,
// Additional data for rendering
columns,
actions,
// Edit modal state
editModalOpen,
editingPrompt,
editPromptFields,
// Edit modal actions
handleSavePrompt,
handleCancelEdit
};
}

View file

@ -1,70 +0,0 @@
import React from 'react';
// Prompt interface based on the API response
export interface Prompt {
id: string;
mandateId: string;
content: string;
name: string;
_createdBy?: string; // Optional field to track who created the prompt
_hideDelete?: boolean; // Backend access control flag
}
// Props for the PromptsTable component
export interface PromptsTableProps {
className?: string;
}
// Action configuration for prompt actions
export interface PromptActionConfig {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy';
title?: string | ((row: Prompt) => string);
onAction?: (row: Prompt) => Promise<void> | void;
disabled?: (row: Prompt) => boolean | { disabled: boolean; message?: string };
loading?: (row: Prompt) => boolean;
}
// Column configuration for the prompts table
export interface PromptColumnConfig {
key: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum';
width: number;
minWidth: number;
maxWidth: number;
sortable: boolean;
filterable: boolean;
searchable?: boolean;
filterOptions?: string[];
formatter: (value: any, row?: any) => React.ReactElement | string;
}
// Return type for the prompts logic hook
export interface PromptsLogicReturn {
// Data
prompts: Prompt[];
loading: boolean;
error: string | null;
// Actions
handleDeleteSingle: (prompt: Prompt) => Promise<void>;
handleDeleteMultiple: (prompts: Prompt[]) => Promise<void>;
handleEditPrompt: (prompt: Prompt) => void;
handleCopyPrompt: (prompt: Prompt) => void;
// Refetch function
refetch: () => Promise<void>;
// Additional data for rendering
columns: PromptColumnConfig[];
actions: PromptActionConfig[];
// Edit modal state
editModalOpen: boolean;
editingPrompt: Prompt | null;
editPromptFields: any[];
// Edit modal actions
handleSavePrompt: (updatedPrompt: Prompt) => Promise<void>;
handleCancelEdit: () => void;
}

View file

@ -7,7 +7,7 @@ import styles from './SidebarStyles/SidebarItem.module.css';
import SidebarSubmenu from "./SidebarSubmenu";
import { SidebarItemProps } from "./sidebarTypes";
const SidebarItem: React.FC<SidebarItemProps> = ({
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
item,
isOpen,
onToggle,
@ -84,6 +84,8 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
{hasSubItems && !isMinimized && !isDisabled && <SidebarSubmenu item={item} isOpen={isOpen} />}
</div>
);
};
});
SidebarItem.displayName = 'SidebarItem';
export default SidebarItem;

View file

@ -2,16 +2,19 @@ import React, { useState, useEffect, useRef } from 'react'
import { useMsal } from '@azure/msal-react'
import { FaSignOutAlt } from 'react-icons/fa'
import styles from './SidebarStyles/SidebarUser.module.css'
import { useCurrentUser, User } from '../../hooks/useUsers'
import { User } from '../../hooks/useUsers'
import { SidebarUserProps } from './sidebarTypes';
import { getUserDataCache, CachedUserData } from '../../utils/userCache';
import { useCurrentUser } from '../../hooks/useUsers';
const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
const { instance } = useMsal();
const { user: currentUser, isLoading: currentUserLoading, logout } = useCurrentUser();
const { logout } = useCurrentUser(); // Only use logout function from hook
// Local state for user data created from currentUser
// Local state for user data created from cached data
const [user, setUser] = useState<User | null>(null);
const [userError, setUserError] = useState<string | null>(null);
const [cachedUserData, setCachedUserData] = useState<CachedUserData | null>(null);
const [showLogoutMenu, setShowLogoutMenu] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
@ -50,40 +53,58 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
}
};
// Use currentUser data directly instead of making API calls
// Load cached user data on mount and when user updates
useEffect(() => {
console.log('🔍 SidebarUser useEffect: currentUser:', currentUser);
const loadCachedUserData = () => {
const cached = getUserDataCache();
setCachedUserData(cached);
if (currentUser?.id) {
console.log('✅ SidebarUser: Using currentUser data directly (avoiding CORS issues)');
// Create a User object from currentUser data with fallback values
const userData: User = {
id: currentUser.id,
username: currentUser.username,
email: currentUser.username, // Use username as email fallback
fullName: currentUser.username.split('@')[0] || currentUser.username, // Extract name from email
language: 'de', // Default language
enabled: true, // Assume enabled if logged in
privilege: currentUser.privilege || 'user',
authenticationAuthority: currentUser.authenticationAuthority || 'local',
mandateId: currentUser.mandateId || ''
};
setUser(userData);
setUserError(null);
console.log('✅ SidebarUser: User data set from currentUser:', userData);
} else {
console.log('⚠️ SidebarUser: No currentUser available');
setUser(null);
}
}, [currentUser]);
if (cached?.id) {
// Create a User object from cached data with fallback values
const userData: User = {
id: cached.id,
username: cached.username,
email: cached.email || cached.username, // Use email or username as fallback
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
language: cached.language || 'de', // Default language
enabled: cached.enabled ?? true, // Assume enabled if logged in
privilege: cached.privilege || 'user',
authenticationAuthority: cached.authenticationAuthority || 'local',
mandateId: cached.mandateId || ''
};
setUser(userData);
setUserError(null);
} else {
setUser(null);
}
};
loadCachedUserData();
}, []); // Empty dependency array - only run on mount
// Listen for user updates from settings page
useEffect(() => {
const handleUserUpdate = () => {
// Trigger re-evaluation of currentUser data
if (currentUser?.id) {
// User data will be updated automatically through currentUser dependency
console.log('🔄 SidebarUser: User info updated event received');
// Refresh cached user data when user info is updated
const cached = getUserDataCache();
setCachedUserData(cached);
if (cached?.id) {
const userData: User = {
id: cached.id,
username: cached.username,
email: cached.email || cached.username,
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
language: cached.language || 'de',
enabled: cached.enabled ?? true,
privilege: cached.privilege || 'user',
authenticationAuthority: cached.authenticationAuthority || 'local',
mandateId: cached.mandateId || ''
};
setUser(userData);
setUserError(null);
} else {
setUser(null);
}
};
@ -91,7 +112,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
return () => {
window.removeEventListener('userInfoUpdated', handleUserUpdate);
};
}, [currentUser?.id]);
}, []); // Empty dependency array - only set up listener once
// Close popup when clicking outside
useEffect(() => {
@ -110,7 +131,7 @@ const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
};
}, [showLogoutMenu]);
if (currentUserLoading) {
if (!cachedUserData) {
return (
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
<div className={styles.userContainer}>Lädt...</div>

View file

@ -2,7 +2,7 @@ import { IoIosCheckmarkCircle, IoIosMail, IoIosCall, IoIosTime, IoIosRefresh } f
import { useNavigate } from 'react-router-dom';
import sharedStyles from '../../core/PageManager/pages.module.css';
import styles from './SpeechConfirmation.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
interface MandateData {
id: string;

View file

@ -1,6 +1,6 @@
import { IoIosLink, IoIosCall, IoIosAnalytics, IoIosFingerPrint, IoIosBook, IoIosChatbubbles, IoIosDesktop } from 'react-icons/io';
import styles from './SpeechInfo.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
function SpeechInfo() {
const { t } = useLanguage();

View file

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
import sharedStyles from '../../core/PageManager/pages.module.css';
import styles from './SpeechSettings.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
interface MandateData {
id: string;

View file

@ -2,7 +2,7 @@ import { useState } from 'react';
import { IoIosArrowBack, IoIosBusiness, IoIosPeople } from 'react-icons/io';
import sharedStyles from '../../core/PageManager/pages.module.css';
import styles from './SpeechSignUp.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
import { useLanguage } from '../../providers/language/LanguageContext';
interface SpeechSignUpProps {
onBack: () => void;

View file

@ -1,40 +0,0 @@
.testSharepointTable {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.errorState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: var(--color-bg);
border: 1px solid var(--color-error, #dc3545);
border-radius: 8px;
color: var(--color-error, #dc3545);
text-align: center;
gap: 15px;
}
.retryButton {
padding: 8px 16px;
background: var(--color-error, #dc3545);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: var(--font-family);
font-size: 14px;
transition: background-color 0.2s ease;
}
.retryButton:hover {
background: var(--color-error-hover, #c82333);
}
.sharepointFormGenerator {
width: 100%;
}

View file

@ -1,72 +0,0 @@
import { FormGenerator } from '../FormGenerator';
import { useLanguage } from '../../contexts/LanguageContext';
import styles from './TestSharepointTable.module.css';
import { useTestSharepointLogic } from './testSharepointLogic';
import type { TestSharepointTableProps } from './testSharepointInterfaces';
export function TestSharepointTable({
className = '',
documents,
documentsLoading,
documentsError,
columns,
actions,
onRowClick
}: TestSharepointTableProps) {
const { t } = useLanguage();
// Get fallback data from hook if props not provided (for backwards compatibility)
const hookData = useTestSharepointLogic();
// Use props data if provided, otherwise fall back to hook data
const actualDocuments = documents ?? hookData.documents;
const actualLoading = documentsLoading ?? hookData.documentsLoading;
const actualError = documentsError ?? hookData.documentsError;
const actualColumns = columns ?? hookData.columns;
const actualActions = actions ?? hookData.actions;
// Debug the data being passed to FormGenerator
console.log('TestSharepointTable - actualDocuments:', actualDocuments);
console.log('TestSharepointTable - actualLoading:', actualLoading);
console.log('TestSharepointTable - actualError:', actualError);
console.log('TestSharepointTable - actualColumns:', actualColumns);
// Show error state
if (actualError) {
return (
<div className={`${styles.testSharepointTable} ${className}`}>
<div className={styles.errorState}>
<p>{t('sharepoint.error.loading', 'Error loading SharePoint documents:')} {actualError}</p>
<button onClick={() => window.location.reload()} className={styles.retryButton}>
{t('sharepoint.button.retry', 'Retry')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.testSharepointTable} ${className}`}>
<FormGenerator
data={actualDocuments}
columns={actualColumns}
title={t('sharepoint.table.title', 'SharePoint Documents')}
loading={actualLoading}
searchable={true}
filterable={true}
sortable={true}
resizable={true}
pagination={true}
pageSize={10}
pageSizeOptions={[10, 25, 50, 100]}
showPageSizeSelector={true}
selectable={false}
onRowClick={onRowClick}
actionButtons={actualActions}
className={styles.sharepointFormGenerator}
/>
</div>
);
}
export default TestSharepointTable;

View file

@ -1,11 +0,0 @@
export { TestSharepointTable } from './TestSharepointTable';
export { useTestSharepointLogic } from './testSharepointLogic';
export type {
TestSharepointTableProps,
TableAction,
SharePointHandlers,
SharePointOperationsReturn,
SharePointConnectionsReturn,
SharePointTableConfig,
TestSharepointLogicReturn
} from './testSharepointInterfaces';

View file

@ -1,112 +0,0 @@
import { ColumnConfig } from '../FormGenerator';
import React from 'react';
// Re-export SharePoint-related interfaces from hooks
export type {
SharePointConnection,
SharePointDocument,
SharePointResponse,
SharePointListRequest,
SharePointFindRequest,
SharePointReadRequest,
SharePointUploadRequest
} from '../../hooks/useSharePointTest';
// Import for local use
import type { SharePointConnection, SharePointDocument } from '../../hooks/useSharePointTest';
// Component Props Interfaces
export interface TestSharepointTableProps {
className?: string;
documents?: SharePointDocument[];
documentsLoading?: boolean;
documentsError?: string | null;
columns?: any[];
actions?: any[];
onRowClick?: (row: any) => void;
}
// Table Action Interface
export interface TableAction {
label: string;
onClick?: (document: SharePointDocument) => Promise<void> | void;
icon: React.ReactNode | ((document: SharePointDocument) => React.ReactNode);
}
// SharePoint Operation Handler Types
export interface SharePointHandlers {
handleConnectionTest: (connectionId: string) => Promise<boolean>;
handleListDocuments: (connectionId: string, siteUrl: string, folderPaths: string[]) => Promise<SharePointDocument[]>;
handleFindDocuments: (connectionId: string, siteUrl: string, query: string) => Promise<SharePointDocument[]>;
handleReadDocument: (connectionId: string, siteUrl: string, documentPath: string) => Promise<{ success: boolean; data?: any; error?: string }>;
}
// Hook Return Types for SharePoint Operations
export interface SharePointOperationsReturn extends SharePointHandlers {
testingConnections: Set<string>;
loadingDocuments: boolean;
connectionError: string | null;
documentsError: string | null;
isLoading: boolean;
}
// Hook Return Types for SharePoint Connections
export interface SharePointConnectionsReturn {
connections: SharePointConnection[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
testConnection: (connectionId: string) => Promise<any>;
}
// SharePoint Table Configuration
export interface SharePointTableConfig {
columns: ColumnConfig[];
actions: TableAction[];
pageSize: number;
searchable: boolean;
filterable: boolean;
sortable: boolean;
resizable: boolean;
pagination: boolean;
}
// Hook Return Type for TestSharepoint Logic
export interface TestSharepointLogicReturn {
// Connection data
connections: SharePointConnection[];
selectedConnection: SharePointConnection | null;
connectionLoading: boolean;
connectionError: string | null;
// Document data
documents: SharePointDocument[];
documentsLoading: boolean;
documentsError: string | null;
// Table configuration
columns: ColumnConfig[];
actions: TableAction[];
// Connection testing
testingConnections: Set<string>;
connectionTestResults: Record<string, any>;
// Site discovery
discoveredSites: any[];
sitesDiscovered: boolean;
// Token debug
tokenDebugInfo: any;
// Handlers
handleSelectConnection: (connectionId: string) => void;
handleTestConnection: (connectionId: string) => Promise<void>;
handleListDocuments: (siteUrl?: string, folderPaths?: string[]) => Promise<void>;
handleDiscoverSites: () => Promise<void>;
handleSelectSite: (siteUrl: string) => void;
handleDebugTokens: () => Promise<void>;
handleCleanupTokens: () => Promise<void>;
handleFolderNavigation: (document: SharePointDocument, currentPath: string) => string;
refetchConnections: () => Promise<void>;
}

View file

@ -1,395 +0,0 @@
import { useMemo, useState, useEffect } from 'react';
import { IoIosLink, IoIosCloudDownload } from 'react-icons/io';
import { ColumnConfig } from '../FormGenerator';
import { useSharePointTest } from '../../hooks/useSharePointTest';
import { useLanguage } from '../../contexts/LanguageContext';
import type {
TableAction,
SharePointConnection,
SharePointDocument,
TestSharepointLogicReturn
} from './testSharepointInterfaces';
export function useTestSharepointLogic(): TestSharepointLogicReturn {
const {
getConnections,
testConnection,
listDocuments,
discoverSites,
debugTokenDetails,
cleanupTokens,
isLoading
} = useSharePointTest();
const { t } = useLanguage();
// State management
const [connections, setConnections] = useState<SharePointConnection[]>([]);
const [selectedConnection, setSelectedConnection] = useState<SharePointConnection | null>(null);
const [connectionLoading, setConnectionLoading] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [documents, setDocuments] = useState<SharePointDocument[]>([]);
const [documentsLoading, setDocumentsLoading] = useState(false);
const [documentsError, setDocumentsError] = useState<string | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<string>>(new Set());
const [connectionTestResults, setConnectionTestResults] = useState<Record<string, any>>({});
const [discoveredSites, setDiscoveredSites] = useState<any[]>([]);
const [sitesDiscovered, setSitesDiscovered] = useState(false);
const [tokenDebugInfo, setTokenDebugInfo] = useState<any>(null);
// Load connections on mount
useEffect(() => {
loadConnections();
}, []);
const loadConnections = async () => {
setConnectionLoading(true);
setConnectionError(null);
try {
const conns = await getConnections();
setConnections(conns);
if (conns.length > 0 && !selectedConnection) {
setSelectedConnection(conns[0]);
}
} catch (error) {
console.error('Failed to load connections:', error);
setConnectionError(error instanceof Error ? error.message : 'Failed to load connections');
} finally {
setConnectionLoading(false);
}
};
// Configure columns for the SharePoint documents table
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'documentName',
label: t('sharepoint.column.documentName', 'Document Name'),
type: 'string',
width: 300,
minWidth: 200,
maxWidth: 400,
sortable: true,
filterable: true,
searchable: true,
formatter: (value: string, row: any) => (
<span
style={{
color: 'var(--color-text)',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '8px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: row?.type === 'folder' ? 'pointer' : 'default'
}}
title={value}
>
{row?.type === 'folder' ? '📁' : '📄'} {value}
</span>
)
},
{
key: 'mimeType',
label: t('sharepoint.column.mimeType', 'MIME Type'),
type: 'string',
width: 200,
minWidth: 150,
maxWidth: 300,
sortable: true,
filterable: true,
searchable: true,
},
{
key: 'size',
label: t('sharepoint.column.size', 'Size'),
type: 'number',
width: 140,
minWidth: 120,
maxWidth: 180,
sortable: true,
filterable: false,
formatter: (value: number | string | undefined) => {
if (!value || value === 0) return '-';
const sizeInBytes = typeof value === 'string' ? parseInt(value, 10) : value;
const units = ['Bytes', 'KB', 'MB', 'GB'];
let size = sizeInBytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return (
<span style={{ fontWeight: 500, color: 'var(--color-text)' }}>
{`${size.toFixed(1)} ${units[unitIndex]}`}
</span>
);
}
},
{
key: 'path',
label: t('sharepoint.column.path', 'Path'),
type: 'string',
width: 250,
minWidth: 200,
maxWidth: 400,
sortable: true,
filterable: true,
searchable: true,
},
], [t]);
// Handle connection selection
const handleSelectConnection = (connectionId: string) => {
const connection = connections.find(conn => conn.id === connectionId);
if (connection) {
setSelectedConnection(connection);
// Clear documents when changing connection
setDocuments([]);
setDocumentsError(null);
}
};
// Handle connection testing
const handleTestConnection = async (connectionId: string) => {
setTestingConnections(prev => new Set(prev).add(connectionId));
try {
const result = await testConnection(connectionId);
setConnectionTestResults(prev => ({ ...prev, [connectionId]: result }));
} catch (error) {
console.error('Connection test failed:', error);
setConnectionTestResults(prev => ({
...prev,
[connectionId]: {
success: false,
error: error instanceof Error ? error.message : 'Connection test failed'
}
}));
} finally {
setTestingConnections(prev => {
const newSet = new Set(prev);
newSet.delete(connectionId);
return newSet;
});
}
};
// Handle listing documents
const handleListDocuments = async (siteUrl?: string, folderPaths?: string[]) => {
if (!selectedConnection) {
setDocumentsError('No connection selected');
return;
}
setDocumentsLoading(true);
setDocumentsError(null);
try {
const connectionReference = `connection:${selectedConnection.authority}:${selectedConnection.externalUsername}:${selectedConnection.id}`;
const response = await listDocuments({
connectionReference,
siteUrl: siteUrl || 'https://your-tenant.sharepoint.com/sites/your-site',
folderPaths: folderPaths || ['/'],
includeSubfolders: false,
expectedDocumentFormats: [{ extension: '.json', mimeType: 'application/json' }]
});
console.log('SharePoint response:', response);
if (response.success && response.data?.documents) {
// Extract the actual files from the nested structure
const documents = response.data.documents;
if (documents.length > 0 && documents[0].documentData?.listResults) {
// Flatten all files from all folder results
const allFiles: SharePointDocument[] = [];
documents[0].documentData.listResults.forEach((folderResult: any) => {
if (folderResult.items && Array.isArray(folderResult.items)) {
folderResult.items.forEach((item: any) => {
// Convert SharePoint item to our document format
allFiles.push({
documentName: item.name || 'Unknown',
mimeType: item.file?.mimeType || (item.type === 'folder' ? 'folder' : 'unknown'),
size: item.size || 0,
path: folderResult.folderPath || '/',
type: item.type || (item.folder ? 'folder' : 'file'),
id: item.id,
documentData: item // Store the full item data
});
});
}
});
console.log('Extracted files:', allFiles);
console.log('Sample file structure:', allFiles[0]);
setDocuments(allFiles);
} else {
console.log('No listResults found in response');
setDocuments([]);
}
} else {
console.log('Response error or no documents:', response);
setDocumentsError(response.error || 'Failed to list documents');
setDocuments([]);
}
} catch (error) {
console.error('Failed to list documents:', error);
setDocumentsError(error instanceof Error ? error.message : 'Failed to list documents');
setDocuments([]);
} finally {
setDocumentsLoading(false);
}
};
// Handle site discovery
const handleDiscoverSites = async () => {
if (!selectedConnection) {
return;
}
try {
const result = await discoverSites();
if (result.success && result.data && result.data.sites) {
setDiscoveredSites(result.data.sites);
setSitesDiscovered(true);
setDocumentsError(null); // Clear any previous errors
} else {
console.error('Site discovery failed:', result);
setDiscoveredSites([]);
setSitesDiscovered(true); // Set to true so we show the "no sites" message
// Set error message to help user understand what went wrong
const errorMsg = result.error || result.message || 'Unknown error occurred';
setDocumentsError(`Site discovery failed: ${errorMsg}`);
}
} catch (error) {
console.error('Site discovery failed:', error);
setDiscoveredSites([]);
setSitesDiscovered(true);
setDocumentsError(`Site discovery error: ${error instanceof Error ? error.message : 'Network or authentication error'}`);
}
};
// Handle site selection
const handleSelectSite = (siteUrl: string) => {
// This will be used by the parent component
console.log('Site selected:', siteUrl);
};
// Handle token debug
const handleDebugTokens = async () => {
try {
const result = await debugTokenDetails();
setTokenDebugInfo(result);
console.log('Token debug info:', result);
} catch (error) {
console.error('Token debug failed:', error);
setTokenDebugInfo({ error: error instanceof Error ? error.message : 'Failed to get token info' });
}
};
// Handle token cleanup
const handleCleanupTokens = async () => {
try {
const result = await cleanupTokens();
console.log('Token cleanup result:', result);
// Clear the debug info to force refresh
setTokenDebugInfo(null);
// Show success message
setDocumentsError(null);
alert(`Success! Deleted ${result.data?.tokensDeleted || 0} tokens. Please reconnect your Microsoft account now.`);
} catch (error) {
console.error('Token cleanup failed:', error);
alert(`Token cleanup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Handle folder navigation
const handleFolderNavigation = (document: SharePointDocument, currentPath: string) => {
if (document.type === 'folder') {
// Build the new path by combining current path with folder name
const newPath = currentPath === '/' ? `/${document.documentName}` : `${currentPath}/${document.documentName}`;
console.log('Navigating to folder:', newPath);
return newPath;
}
return currentPath;
};
// Handle document actions
const handleViewDocument = async (document: SharePointDocument) => {
console.log('View document:', document);
// TODO: Implement document viewing
};
const handleDownloadDocument = async (document: SharePointDocument) => {
console.log('Download document:', document);
// TODO: Implement document download
};
// Configure action buttons
const actions: TableAction[] = useMemo(() => [
{
label: t('sharepoint.action.view', 'View'),
icon: <IoIosLink />,
onClick: (document: SharePointDocument) => {
handleViewDocument(document);
}
},
{
label: t('sharepoint.action.download', 'Download'),
icon: <IoIosCloudDownload />,
onClick: (document: SharePointDocument) => {
handleDownloadDocument(document);
}
}
], [t]);
// Refetch connections
const refetchConnections = async () => {
await loadConnections();
};
return {
// Connection data
connections,
selectedConnection,
connectionLoading,
connectionError,
// Document data
documents,
documentsLoading: documentsLoading || isLoading,
documentsError,
// Table configuration
columns,
actions,
// Connection testing
testingConnections,
connectionTestResults,
// Site discovery
discoveredSites,
sitesDiscovered,
// Token debug
tokenDebugInfo,
// Handlers
handleSelectConnection,
handleTestConnection,
handleListDocuments,
handleDiscoverSites,
handleSelectSite,
handleDebugTokens,
handleCleanupTokens,
handleFolderNavigation,
refetchConnections
};
}

View file

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { CreateButtonProps } from '../ButtonTypes';
import Button from '../Button';
import { Popup, EditForm } from '../../Popup';
import { useLanguage } from '../../../../contexts/LanguageContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const CreateButton: React.FC<CreateButtonProps> = ({
onCreate,

View file

@ -0,0 +1,133 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--background-secondary, #f5f5f5);
border-radius: 8px;
padding: 1rem;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.title {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #333);
}
.count {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.fileList {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
flex: 1;
}
.fileItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: var(--background-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
transition: all 0.2s ease;
}
.fileItem:hover {
border-color: var(--border-hover, #ccc);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.fileInfo {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.fileName {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileMeta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.fileSize {
font-weight: 400;
}
.fileSource {
padding: 0.125rem 0.5rem;
background: var(--background-tertiary, #f0f0f0);
border-radius: 4px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fileActions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
margin-left: 0.75rem;
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
text-align: center;
flex: 1;
}
/* Scrollbar styling */
.fileList::-webkit-scrollbar {
width: 6px;
}
.fileList::-webkit-scrollbar-track {
background: transparent;
}
.fileList::-webkit-scrollbar-thumb {
background: var(--border-color, #ccc);
border-radius: 3px;
}
.fileList::-webkit-scrollbar-thumb:hover {
background: var(--border-hover, #999);
}

View file

@ -0,0 +1,218 @@
import React, { useMemo } from 'react';
import { ViewActionButton, DeleteActionButton, RemoveActionButton } from '../../FormGenerator/ActionButtons';
import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
export interface ConnectedFilesListProps {
files: WorkflowFile[];
pendingFiles?: WorkflowFile[];
onDelete: (file: WorkflowFile) => Promise<void>;
onRemove?: (file: WorkflowFile) => Promise<void>;
onAttach?: (fileId: string) => Promise<void>; // New: attach file for next message
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
workflowId?: string;
emptyMessage?: string;
}
export function ConnectedFilesList({
files,
pendingFiles = [],
onDelete,
onRemove,
onAttach,
deletingFiles = new Set(),
previewingFiles = new Set(),
removingFiles = new Set(),
workflowId,
emptyMessage = 'No files connected to this workflow'
}: ConnectedFilesListProps) {
// Combine workflow files and pending files, deduplicating by fileId
const allFiles = useMemo(() => {
const fileMap = new Map<string, WorkflowFile>();
// Add workflow files first (filter out files without fileId)
files.forEach(file => {
if (file.fileId && file.fileId.trim() !== '') {
fileMap.set(file.fileId, file);
}
});
// Add pending files (may override workflow files if same fileId)
pendingFiles.forEach(file => {
if (file.fileId && file.fileId.trim() !== '') {
fileMap.set(file.fileId, file);
}
});
return Array.from(fileMap.values());
}, [files, pendingFiles]);
// Create hookData object for action buttons
const hookData = useMemo(() => ({
handleDelete: async (fileId: string) => {
const file = allFiles.find(f => f.fileId === fileId);
if (file) {
await onDelete(file);
return true;
}
return false;
},
removeOptimistically: (fileId: string) => {
// This will be handled by the parent component's state
},
refetch: async () => {
// Refetch handled by parent
},
deletingItems: deletingFiles,
previewingFiles: previewingFiles
}), [allFiles, onDelete, deletingFiles, previewingFiles]);
const handleView = async (file: WorkflowFile) => {
// View is handled by ViewActionButton's FilePreview component
return Promise.resolve();
};
const handleRemove = async (file: WorkflowFile) => {
// Remove file from workflow (not delete from backend)
if (onRemove) {
await onRemove(file);
}
};
if (allFiles.length === 0) {
return (
<div className={styles.container}>
<div className={styles.emptyState}>
{emptyMessage}
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h3 className={styles.title}>Connected Files</h3>
<span className={styles.count}>({allFiles.length})</span>
</div>
<div className={styles.fileList}>
{allFiles
.filter(file => file.fileId && file.fileId.trim() !== '') // Ensure fileId exists
.map((file, index) => {
const isDeleting = deletingFiles.has(file.fileId!);
const isPreviewing = previewingFiles.has(file.fileId!);
const isRemoving = removingFiles.has(file.fileId!);
// Use fileId as key since we've filtered out files without it
const uniqueKey = file.fileId!;
// Check if file is in pending files (can be removed) or in messages (already sent)
const isPendingFile = pendingFiles.some(f => f.fileId === file.fileId);
// Handle clicking on file item to attach/detach for next message
const handleFileItemClick = async (e: React.MouseEvent) => {
// Don't trigger if clicking on action buttons - they handle their own clicks
const target = e.target as HTMLElement;
const fileActionsElement = target.closest(`.${styles.fileActions}`);
const buttonElement = target.closest('button');
if (fileActionsElement || buttonElement) {
e.stopPropagation();
return;
}
// Prevent default and stop propagation to ensure click handler fires
e.preventDefault();
e.stopPropagation();
if (onAttach && file.fileId) {
console.log('🖱️ ConnectedFilesList: Clicking file to attach/detach:', file.fileId);
await onAttach(file.fileId);
}
};
return (
<div
key={uniqueKey}
className={styles.fileItem}
onClick={handleFileItemClick}
style={{
cursor: onAttach ? 'pointer' : 'default',
userSelect: 'none' // Prevent text selection on click
}}
title={onAttach ? (isPendingFile ? 'Click to detach from next message' : 'Click to attach for next message') : undefined}
>
<div className={styles.fileInfo}>
<div className={styles.fileName} title={file.fileName}>
{file.fileName}
{onAttach && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#666' }}>
{isPendingFile ? '(click to detach)' : '(click to attach)'}
</span>
)}
</div>
<div className={styles.fileMeta}>
<span className={styles.fileSize}>
{formatFileSize(file.fileSize)}
</span>
{file.source && (
<span className={styles.fileSource}>
{file.source === 'user_uploaded' ? 'Uploaded' : 'AI Created'}
</span>
)}
{isPendingFile && (
<span style={{ fontSize: '0.75rem', color: '#4CAF50', fontWeight: 500 }}>
Attached
</span>
)}
</div>
</div>
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
<ViewActionButton
row={file}
onView={handleView}
disabled={isDeleting || isRemoving}
loading={isPreviewing}
hookData={hookData}
idField="fileId"
nameField="fileName"
typeField="mimeType"
/>
{isPendingFile && onRemove && (
<RemoveActionButton
row={file}
onRemove={handleRemove}
disabled={isDeleting}
loading={isRemoving}
hookData={hookData}
idField="fileId"
loadingStateName="removingItems"
/>
)}
<DeleteActionButton
row={file}
hookData={hookData}
idField="fileId"
operationName="handleDelete"
loadingStateName="deletingItems"
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
export default ConnectedFilesList;

View file

@ -0,0 +1,6 @@
export { default as ConnectedFilesList } from './ConnectedFilesList';
export type { ConnectedFilesListProps } from './ConnectedFilesList';

View file

@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './DragDropOverlay.module.css';
import { IoFolderOpen } from 'react-icons/io5';

View file

@ -0,0 +1,270 @@
.dropdownContainer {
position: relative;
display: inline-block;
}
.dropdownButton {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--color-bg);
color: var(--color-text);
min-width: 100%;
box-sizing: border-box;
}
.dropdownButton:hover:not(.disabled):not(:disabled) {
border-color: var(--color-secondary);
background-color: var(--color-secondary);
color: white;
}
.dropdownButton:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 0, 123, 255), 0.1);
}
.dropdownButton.disabled,
.dropdownButton:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: var(--color-bg-disabled, #f5f5f5);
}
.dropdownButton.loading {
cursor: wait;
}
/* Button variants */
.buttonPrimary {
border-color: var(--color-primary);
color: var(--color-text);
}
.buttonPrimary:hover:not(.disabled):not(:disabled) {
background-color: var(--color-primary);
color: white;
}
.buttonSecondary {
border-color: var(--color-secondary);
color: var(--color-text);
}
.buttonSecondary:hover:not(.disabled):not(:disabled) {
background-color: var(--color-secondary);
color: white;
}
.buttonDanger {
border-color: #ef4444;
color: var(--color-text);
}
.buttonDanger:hover:not(.disabled):not(:disabled) {
background-color: #ef4444;
color: white;
}
.buttonSuccess {
border-color: #10b981;
color: var(--color-text);
}
.buttonSuccess:hover:not(.disabled):not(:disabled) {
background-color: #10b981;
color: white;
}
.buttonWarning {
border-color: #f59e0b;
color: var(--color-text);
}
.buttonWarning:hover:not(.disabled):not(:disabled) {
background-color: #f59e0b;
color: white;
}
/* Button sizes */
.buttonSm {
padding: 8px 12px;
font-size: 13px;
}
.buttonMd {
padding: 12px 16px;
font-size: 14px;
}
.buttonLg {
padding: 16px 20px;
font-size: 16px;
}
.buttonIcon {
font-size: 16px;
flex-shrink: 0;
}
.buttonText {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevronIcon {
font-size: 16px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.clearIcon {
font-size: 18px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.clearIcon:hover {
transform: scale(1.1);
}
.buttonSpinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-primary);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dropdownMenu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background-color: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
overflow: hidden;
min-width: 100%;
}
.dropdownHeader {
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dropdownItems {
max-height: inherit;
overflow-y: auto;
}
.dropdownEmpty {
padding: 12px 16px;
font-size: 14px;
color: var(--color-text);
font-style: italic;
text-align: center;
}
.dropdownItem {
width: 100%;
padding: 12px 16px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
transition: all 0.2s ease;
border-bottom: 1px solid var(--color-primary);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.dropdownItem:last-child {
border-bottom: none;
}
.dropdownItem:hover {
background-color: var(--color-secondary);
color: white;
}
.dropdownItemSelected {
background-color: var(--color-primary);
color: white;
font-weight: 500;
}
.dropdownItemSelected:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
.itemLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.selectedIndicator {
font-size: 16px;
font-weight: bold;
flex-shrink: 0;
}
/* Dark theme support */
[data-theme="dark"] .dropdownButton {
border-color: var(--color-primary);
background-color: var(--color-bg);
}
[data-theme="dark"] .dropdownMenu {
background-color: var(--color-bg);
border-color: var(--color-primary);
}
[data-theme="dark"] .dropdownItem {
color: var(--color-text);
}
/* Responsive design */
@media (max-width: 640px) {
.dropdownButton {
font-size: 16px; /* Prevents zoom on iOS */
}
}

View file

@ -0,0 +1,240 @@
import React, { useState, useRef, useEffect } from 'react';
import { IconType } from 'react-icons';
import { IoChevronDown, IoClose } from 'react-icons/io5';
import styles from './DropdownSelect.module.css';
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
export interface DropdownSelectItem<T = any> {
id: string | number;
label: string;
value: T;
metadata?: Record<string, any>; // Additional data for custom rendering
}
export interface DropdownSelectProps<T = any> {
items: DropdownSelectItem<T>[];
selectedItemId?: string | number | null;
onSelect: (item: DropdownSelectItem<T> | null) => void;
placeholder?: string;
emptyMessage?: string;
headerText?: string;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconType;
disabled?: boolean;
loading?: boolean;
className?: string;
renderItem?: (item: DropdownSelectItem<T>, isSelected: boolean) => React.ReactNode;
renderButton?: (selectedItem: DropdownSelectItem<T> | null, isOpen: boolean) => React.ReactNode;
renderClearButton?: (selectedItem: DropdownSelectItem<T>, onClear: () => void) => React.ReactNode;
minWidth?: string;
maxHeight?: string;
showClearButton?: boolean; // Enable/disable clear button feature
clearButtonLabel?: string; // Label for clear button (defaults to showing selected item name)
}
function DropdownSelect<T = any>({
items = [],
selectedItemId,
onSelect,
placeholder = 'Select an item',
emptyMessage = 'No items available',
headerText,
variant = 'primary',
size = 'md',
icon: Icon,
disabled = false,
loading = false,
className = '',
renderItem,
renderButton,
renderClearButton,
minWidth = '180px',
maxHeight = '300px',
showClearButton = true,
clearButtonLabel
}: DropdownSelectProps<T>) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Find selected item
const selectedItem = selectedItemId !== null && selectedItemId !== undefined
? items.find(item => item.id === selectedItemId) || null
: null;
// Handle item selection
const handleItemClick = (item: DropdownSelectItem<T>) => {
onSelect(item);
setIsOpen(false);
};
// Handle clear selection
const handleClear = () => {
if (!disabled && !loading) {
onSelect(null);
setIsOpen(false);
}
};
// Toggle dropdown
const toggleDropdown = () => {
if (!disabled && !loading) {
setIsOpen(!isOpen);
}
};
// Build button classes
const buttonClasses = [
styles.dropdownButton,
styles[`button${variant.charAt(0).toUpperCase() + variant.slice(1)}`],
styles[`button${size.charAt(0).toUpperCase() + size.slice(1)}`],
loading ? styles.loading : '',
disabled ? styles.disabled : '',
className
].filter(Boolean).join(' ');
// Render default button content
const renderDefaultButton = () => {
if (loading) {
return (
<>
<div className={styles.buttonSpinner} />
<span>{placeholder}</span>
</>
);
}
if (selectedItem) {
return (
<>
{Icon && <Icon className={styles.buttonIcon} />}
<span className={styles.buttonText}>{selectedItem.label}</span>
<IoChevronDown className={`${styles.chevronIcon} ${isOpen ? styles.chevronOpen : ''}`} />
</>
);
}
return (
<>
{Icon && <Icon className={styles.buttonIcon} />}
<span className={styles.buttonText}>{placeholder}</span>
<IoChevronDown className={`${styles.chevronIcon} ${isOpen ? styles.chevronOpen : ''}`} />
</>
);
};
// Render clear button when item is selected and showClearButton is true
const renderClearButtonContent = () => {
if (renderClearButton && selectedItem) {
return renderClearButton(selectedItem, handleClear);
}
if (selectedItem && showClearButton) {
return (
<button
type="button"
className={buttonClasses}
onClick={handleClear}
disabled={disabled || loading}
title={clearButtonLabel || `Clear selection: ${selectedItem.label}`}
>
{Icon && <Icon className={styles.buttonIcon} />}
<span className={styles.buttonText}>
{clearButtonLabel || selectedItem.label}
</span>
<IoClose className={styles.clearIcon} />
</button>
);
}
return null;
};
return (
<div ref={dropdownRef} className={styles.dropdownContainer} style={{ minWidth }}>
{/* Show clear button if item is selected and showClearButton is enabled */}
{selectedItem && showClearButton ? (
renderClearButtonContent()
) : renderButton ? (
<div className={buttonClasses} onClick={toggleDropdown}>
{renderButton(selectedItem, isOpen)}
</div>
) : (
<button
type="button"
className={buttonClasses}
onClick={toggleDropdown}
disabled={disabled || loading}
>
{renderDefaultButton()}
</button>
)}
{isOpen && (
<div className={styles.dropdownMenu} style={{ maxHeight }}>
{headerText && (
<div className={styles.dropdownHeader}>
{headerText}
</div>
)}
{items.length === 0 ? (
<div className={styles.dropdownEmpty}>
{emptyMessage}
</div>
) : (
<div className={styles.dropdownItems}>
{items.map((item) => {
const isSelected = selectedItemId === item.id;
if (renderItem) {
return (
<div
key={item.id}
className={`${styles.dropdownItem} ${isSelected ? styles.dropdownItemSelected : ''}`}
onClick={() => handleItemClick(item)}
>
{renderItem(item, isSelected)}
</div>
);
}
return (
<button
key={item.id}
type="button"
className={`${styles.dropdownItem} ${isSelected ? styles.dropdownItemSelected : ''}`}
onClick={() => handleItemClick(item)}
>
<span className={styles.itemLabel}>{item.label}</span>
{isSelected && <span className={styles.selectedIndicator}></span>}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
export default DropdownSelect;

View file

@ -0,0 +1,3 @@
export { default as DropdownSelect } from './DropdownSelect';
export type { DropdownSelectItem, DropdownSelectProps } from './DropdownSelect';

View file

@ -0,0 +1,95 @@
import React from 'react';
import { DropdownSelect, DropdownSelectItem } from '../../DropdownSelect';
import { ButtonSize } from '../../Button/ButtonTypes';
export interface SelectFieldOption {
id: string | number;
label: string;
value: any;
}
export interface SelectFieldProps {
value?: string | number | null;
onChange?: (value: any) => void;
label?: string;
options: SelectFieldOption[];
placeholder?: string;
required?: boolean;
disabled?: boolean;
description?: string;
className?: string;
size?: ButtonSize;
}
const SelectField: React.FC<SelectFieldProps> = ({
value,
onChange,
label,
options,
placeholder = 'Select an option',
required = false,
disabled = false,
description,
className = '',
size = 'md'
}) => {
// Convert options to DropdownSelectItem format
const items: DropdownSelectItem[] = options.map(opt => ({
id: opt.id,
label: opt.label,
value: opt.value
}));
// Find selected item ID from value
const selectedItemId = value !== null && value !== undefined
? options.find(opt => opt.value === value || opt.id === value)?.id ?? null
: null;
const handleSelect = (item: DropdownSelectItem | null) => {
if (onChange) {
onChange(item ? item.value : null);
}
};
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<DropdownSelect
items={items}
selectedItemId={selectedItemId}
onSelect={handleSelect}
placeholder={placeholder}
disabled={disabled}
size={size}
variant="primary"
showClearButton={false}
minWidth="100%"
/>
</div>
);
};
export default SelectField;

View file

@ -0,0 +1,3 @@
export { default } from './SelectField';
export type { SelectFieldProps, SelectFieldOption } from './SelectField';

View file

@ -0,0 +1,76 @@
import React from 'react';
import TextField from '../../TextField';
import { TextFieldSize } from '../../TextField/TextFieldTypes';
export interface TextInputFieldProps {
value?: string;
onChange?: (value: string) => void;
label?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
readonly?: boolean;
type?: 'text' | 'email' | 'tel';
description?: string;
error?: string;
className?: string;
size?: TextFieldSize;
}
const TextInputField: React.FC<TextInputFieldProps> = ({
value = '',
onChange,
label,
placeholder,
required = false,
disabled = false,
readonly = false,
type = 'text',
description,
error,
className = '',
size = 'md'
}) => {
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
{required && <span style={{ color: 'var(--color-error)', marginLeft: '4px' }}>*</span>}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<TextField
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
disabled={disabled}
readonly={readonly}
type={type}
error={error}
label={undefined} // Don't use TextField's label since we render it above
size={size}
/>
</div>
);
};
export default TextInputField;

View file

@ -0,0 +1,3 @@
export { default } from './TextInputField';
export type { TextInputFieldProps } from './TextInputField';

View file

@ -0,0 +1,111 @@
import React from 'react';
export interface ToggleFieldProps {
value?: boolean;
onChange?: (value: boolean) => void;
label?: string;
description?: string;
disabled?: boolean;
className?: string;
}
const ToggleField: React.FC<ToggleFieldProps> = ({
value = false,
onChange,
label,
description,
disabled = false,
className = ''
}) => {
const handleToggle = () => {
if (!disabled && onChange) {
onChange(!value);
}
};
return (
<div className={className}>
{label && (
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
fontFamily: 'var(--font-family)'
}}>
{label}
</label>
{description && (
<div style={{
fontSize: '0.75rem',
color: 'var(--color-primary)',
marginTop: '4px',
fontStyle: 'italic'
}}>
{description}
</div>
)}
</div>
)}
<button
type="button"
onClick={handleToggle}
disabled={disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
borderRadius: '25px',
border: `1px solid ${value ? 'var(--color-secondary)' : 'var(--color-primary)'}`,
background: value ? 'var(--color-secondary)' : 'var(--color-bg)',
color: value ? 'white' : 'var(--color-text)',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.3s ease',
fontFamily: 'var(--font-family)',
fontSize: '0.875rem',
fontWeight: 500,
opacity: disabled ? 0.6 : 1,
outline: 'none'
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(63, 81, 181, 0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
background: value ? 'rgba(255, 255, 255, 0.3)' : 'var(--color-gray-light)',
position: 'relative',
transition: 'all 0.3s ease'
}}
>
<div
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'white',
position: 'absolute',
top: '2px',
left: value ? '22px' : '2px',
transition: 'left 0.3s ease',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
}}
/>
</div>
<span>{value ? 'Enabled' : 'Disabled'}</span>
</button>
</div>
);
};
export default ToggleField;

View file

@ -0,0 +1,3 @@
export { default } from './ToggleField';
export type { ToggleFieldProps } from './ToggleField';

View file

@ -0,0 +1,7 @@
export { default as TextInputField } from './TextInputField';
export { default as SelectField } from './SelectField';
export { default as ToggleField } from './ToggleField';
export type { TextInputFieldProps } from './TextInputField';
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
export type { ToggleFieldProps } from './ToggleField';

View file

@ -0,0 +1,30 @@
.locationInputContainer {
width: 100%;
}
.inputRow {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.inputWrapper {
flex: 1;
}
.locationButton {
white-space: nowrap;
margin-top: 1.5rem; /* Align with input field */
}
@media (max-width: 768px) {
.inputRow {
flex-direction: column;
}
.locationButton {
margin-top: 0;
width: 100%;
}
}

View file

@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Button, TextField } from '../index';
import { FaLocationArrow } from 'react-icons/fa';
import styles from './LocationInput.module.css';
export interface LocationInputProps {
value: string;
onChange: (value: string) => void;
onUseCurrentLocation: () => void;
isGettingLocation?: boolean;
placeholder?: string;
label?: string;
error?: string;
helperText?: string;
disabled?: boolean;
}
const LocationInput: React.FC<LocationInputProps> = ({
value,
onChange,
onUseCurrentLocation,
isGettingLocation = false,
placeholder = 'Kanton, Gemeinde, Adresse oder Parzelle',
label = 'Standort',
error,
helperText,
disabled = false
}) => {
const [isRequestingLocation, setIsRequestingLocation] = useState(false);
const handleUseCurrentLocation = async () => {
setIsRequestingLocation(true);
try {
await onUseCurrentLocation();
} finally {
setIsRequestingLocation(false);
}
};
return (
<div className={styles.locationInputContainer}>
<div className={styles.inputRow}>
<div className={styles.inputWrapper}>
<TextField
value={value}
onChange={onChange}
placeholder={placeholder}
label={label}
error={error}
helperText={helperText}
disabled={disabled}
size="md"
type="text"
/>
</div>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={disabled || isGettingLocation || isRequestingLocation}
loading={isGettingLocation || isRequestingLocation}
className={styles.locationButton}
>
Meine Position verwenden
</Button>
</div>
</div>
);
};
export default LocationInput;

View file

@ -0,0 +1,3 @@
export { default as LocationInput } from './LocationInput';
export type { LocationInputProps } from './LocationInput';

View file

@ -0,0 +1,25 @@
import proj4 from 'proj4';
// Define LV95 projection (EPSG:2056) and WGS84 (EPSG:4326)
// LV95 / Swiss TM 35
const LV95_PROJ = '+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs';
const WGS84_PROJ = 'EPSG:4326';
/**
* Convert LV95 coordinates to WGS84 (lat/lon)
* Uses proj4 for accurate coordinate transformation
*/
export function lv95ToWGS84(x: number, y: number): { lat: number; lon: number } {
const [lon, lat] = proj4(LV95_PROJ, WGS84_PROJ, [x, y]);
return { lat, lon };
}
/**
* Convert WGS84 (lat/lon) to LV95 coordinates
* Uses proj4 for accurate coordinate transformation
*/
export function wgs84ToLV95(lat: number, lon: number): { x: number; y: number } {
const [x, y] = proj4(WGS84_PROJ, LV95_PROJ, [lon, lat]);
return { x, y };
}

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