added PEK pages
This commit is contained in:
parent
8a0e5f88a1
commit
101b3063c0
207 changed files with 11488 additions and 11968 deletions
623
docs/API_ROUTES_DOCUMENTATION.md
Normal file
623
docs/API_ROUTES_DOCUMENTATION.md
Normal 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
83
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
42
src/App.tsx
42
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
34
src/api.ts
34
src/api.ts
|
|
@ -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
309
src/api/workflowApi.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { default } from './DashboardChatArea';
|
||||
export type { DashboardChatAreaProps } from './dashboardChatAreaTypes';
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../FilePreview.module.css';
|
||||
|
||||
interface ErrorRendererProps {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../FilePreview.module.css';
|
||||
|
||||
export function LoadingRenderer() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../FilePreview.module.css';
|
||||
|
||||
interface UnsupportedRendererProps {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as ConnectActionButton } from './ConnectActionButton';
|
||||
export type { ConnectActionButtonProps } from './ConnectActionButton';
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { PlayActionButton } from './PlayActionButton';
|
||||
export type { PlayActionButtonProps } from './PlayActionButton';
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { default as RemoveActionButton } from './RemoveActionButton';
|
||||
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { default as PromptsTable } from './PromptsTable';
|
||||
export { usePromptsLogic } from './promptsLogic';
|
||||
export type * from './promptsTypes';
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export { TestSharepointTable } from './TestSharepointTable';
|
||||
export { useTestSharepointLogic } from './testSharepointLogic';
|
||||
export type {
|
||||
TestSharepointTableProps,
|
||||
TableAction,
|
||||
SharePointHandlers,
|
||||
SharePointOperationsReturn,
|
||||
SharePointConnectionsReturn,
|
||||
SharePointTableConfig,
|
||||
TestSharepointLogicReturn
|
||||
} from './testSharepointInterfaces';
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
6
src/components/UiComponents/ConnectedFilesList/index.ts
Normal file
6
src/components/UiComponents/ConnectedFilesList/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as ConnectedFilesList } from './ConnectedFilesList';
|
||||
export type { ConnectedFilesListProps } from './ConnectedFilesList';
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
240
src/components/UiComponents/DropdownSelect/DropdownSelect.tsx
Normal file
240
src/components/UiComponents/DropdownSelect/DropdownSelect.tsx
Normal 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;
|
||||
|
||||
3
src/components/UiComponents/DropdownSelect/index.ts
Normal file
3
src/components/UiComponents/DropdownSelect/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as DropdownSelect } from './DropdownSelect';
|
||||
export type { DropdownSelectItem, DropdownSelectProps } from './DropdownSelect';
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default } from './SelectField';
|
||||
export type { SelectFieldProps, SelectFieldOption } from './SelectField';
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default } from './TextInputField';
|
||||
export type { TextInputFieldProps } from './TextInputField';
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default } from './ToggleField';
|
||||
export type { ToggleFieldProps } from './ToggleField';
|
||||
|
||||
7
src/components/UiComponents/EditFields/index.ts
Normal file
7
src/components/UiComponents/EditFields/index.ts
Normal 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';
|
||||
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
73
src/components/UiComponents/LocationInput/LocationInput.tsx
Normal file
73
src/components/UiComponents/LocationInput/LocationInput.tsx
Normal 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;
|
||||
|
||||
3
src/components/UiComponents/LocationInput/index.ts
Normal file
3
src/components/UiComponents/LocationInput/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as LocationInput } from './LocationInput';
|
||||
export type { LocationInputProps } from './LocationInput';
|
||||
|
||||
25
src/components/UiComponents/MapView/LV95Converter.ts
Normal file
25
src/components/UiComponents/MapView/LV95Converter.ts
Normal 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
Loading…
Reference in a new issue