feat: add Monaco editor, API error handling, and CI/CD
- Monaco Editor: script editor with syntax highlighting - API: error handling with ApiError class and token management - CI/CD: Gitea Actions workflow for build, Docker, and deploy - Docker: multi-stage build with nginx - nginx.conf: SPA fallback and API proxy
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.viaeon.com
|
||||||
|
IMAGE_NAME: admin/taskpool-react
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: web
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: web/dist
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: web/dist
|
||||||
|
|
||||||
|
- name: Login to Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Deploy to server
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD }}
|
||||||
|
script: |
|
||||||
|
cd /opt/taskpool
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker image prune -f
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY web/package.json web/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY web/ ./
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
+18
-22
@@ -1,25 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
baihu:
|
frontend:
|
||||||
build:
|
image: git.viaeon.com/admin/taskpool-react:latest
|
||||||
context: .
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "8052:8052"
|
- "3000:80"
|
||||||
volumes:
|
depends_on:
|
||||||
- ./data:/app/data
|
- backend
|
||||||
- ./configs:/app/configs
|
restart: unless-stopped
|
||||||
- ./envs:/app/envs
|
|
||||||
environment:
|
backend:
|
||||||
- TZ=Asia/Shanghai
|
image: git.viaeon.com/admin/taskpool:latest
|
||||||
# 以下环境变量可覆盖配置文件(可选)
|
environment:
|
||||||
# - BH_SERVER_PORT=8052
|
- DB_TYPE=sqlite
|
||||||
# - BH_SERVER_HOST=0.0.0.0
|
- DB_PATH=/data/taskpool.db
|
||||||
# - BH_DB_TYPE=mysql
|
volumes:
|
||||||
# - BH_DB_HOST=localhost
|
- ./data:/data
|
||||||
# - BH_DB_PORT=3306
|
ports:
|
||||||
# - BH_DB_USER=root
|
- "8080:8080"
|
||||||
# - BH_DB_PASSWORD=password
|
|
||||||
# - BH_DB_NAME=baihu
|
|
||||||
# - BH_DB_TABLE_PREFIX=baihu_
|
|
||||||
# - BH_SECRET=your_secret_key
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -9,11 +9,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.80.0",
|
"@tanstack/react-query": "^5.80.0",
|
||||||
"@tanstack/react-router": "^1.120.0",
|
"@tanstack/react-router": "^1.120.0",
|
||||||
"@tanstack/react-table": "^8.21.0",
|
"@tanstack/react-table": "^8.21.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
Generated
+57
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@monaco-editor/react':
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.80.0
|
specifier: ^5.80.0
|
||||||
version: 5.101.2(react@19.2.7)
|
version: 5.101.2(react@19.2.7)
|
||||||
@@ -85,6 +88,16 @@ packages:
|
|||||||
'@emnapi/wasi-threads@1.2.2':
|
'@emnapi/wasi-threads@1.2.2':
|
||||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||||
|
|
||||||
|
'@monaco-editor/loader@1.7.0':
|
||||||
|
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||||
|
|
||||||
|
'@monaco-editor/react@4.7.0':
|
||||||
|
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
|
||||||
|
peerDependencies:
|
||||||
|
monaco-editor: '>= 0.25.0 < 1'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.6':
|
'@napi-rs/wasm-runtime@1.1.6':
|
||||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -276,6 +289,9 @@ packages:
|
|||||||
'@types/react@19.2.17':
|
'@types/react@19.2.17':
|
||||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.3':
|
'@vitejs/plugin-react@6.0.3':
|
||||||
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -381,6 +397,9 @@ packages:
|
|||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
|
dompurify@3.2.7:
|
||||||
|
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.393:
|
electron-to-chromium@1.5.393:
|
||||||
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
||||||
|
|
||||||
@@ -523,6 +542,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
marked@14.0.0:
|
||||||
|
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
monaco-editor@0.55.1:
|
||||||
|
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||||
|
|
||||||
motion-dom@11.18.1:
|
motion-dom@11.18.1:
|
||||||
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
||||||
|
|
||||||
@@ -619,6 +646,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
state-local@1.0.7:
|
||||||
|
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||||
|
|
||||||
tailwindcss@4.3.3:
|
tailwindcss@4.3.3:
|
||||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||||
|
|
||||||
@@ -735,6 +765,17 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@monaco-editor/loader@1.7.0':
|
||||||
|
dependencies:
|
||||||
|
state-local: 1.0.7
|
||||||
|
|
||||||
|
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@monaco-editor/loader': 1.7.0
|
||||||
|
monaco-editor: 0.55.1
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.11.1
|
'@emnapi/core': 1.11.1
|
||||||
@@ -878,6 +919,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))':
|
'@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@22.20.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
@@ -963,6 +1007,10 @@ snapshots:
|
|||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
dompurify@3.2.7:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
electron-to-chromium@1.5.393: {}
|
electron-to-chromium@1.5.393: {}
|
||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
@@ -1054,6 +1102,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
|
|
||||||
|
marked@14.0.0: {}
|
||||||
|
|
||||||
|
monaco-editor@0.55.1:
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.2.7
|
||||||
|
marked: 14.0.0
|
||||||
|
|
||||||
motion-dom@11.18.1:
|
motion-dom@11.18.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-utils: 11.18.1
|
motion-utils: 11.18.1
|
||||||
@@ -1160,6 +1215,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
state-local@1.0.7: {}
|
||||||
|
|
||||||
tailwindcss@4.3.3: {}
|
tailwindcss@4.3.3: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|||||||
+84
-29
@@ -1,21 +1,40 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import * as api from '@/api/endpoints'
|
import * as api from './index'
|
||||||
|
import type * as types from './types'
|
||||||
|
|
||||||
// Auth
|
// Re-export types
|
||||||
|
export type * from './types'
|
||||||
|
|
||||||
|
// Auth hooks
|
||||||
export function useUser() {
|
export function useUser() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['user'],
|
queryKey: ['user'],
|
||||||
queryFn: api.getCurrentUser,
|
queryFn: api.getCurrentUser,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard
|
export function useLogin() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ username, password }: { username: string; password: string }) =>
|
||||||
|
api.login(username, password),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.logout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard hooks
|
||||||
export function useMonitorStats() {
|
export function useMonitorStats() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['monitor'],
|
queryKey: ['monitor'],
|
||||||
queryFn: api.getMonitor,
|
queryFn: api.getMonitorStats,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
|
retry: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +46,8 @@ export function useTaskStats() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tasks
|
// Task hooks
|
||||||
export function useTasks(params?: { status?: string; tag?: string }) {
|
export function useTasks(params?: Record<string, string>) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['tasks', params],
|
queryKey: ['tasks', params],
|
||||||
queryFn: () => api.getTasks(params),
|
queryFn: () => api.getTasks(params),
|
||||||
@@ -47,15 +66,17 @@ export function useCreateTask() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.createTask,
|
mutationFn: api.createTask,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['taskStats'] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateTask() {
|
export function useUpdateTask() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) =>
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data),
|
||||||
api.updateTask(id, data),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -64,7 +85,10 @@ export function useDeleteTask() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.deleteTask,
|
mutationFn: api.deleteTask,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['taskStats'] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +100,14 @@ export function useExecuteTask() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scripts
|
export function useTags() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: api.getTags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script hooks
|
||||||
export function useScripts() {
|
export function useScripts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['scripts'],
|
queryKey: ['scripts'],
|
||||||
@@ -100,6 +131,14 @@ export function useCreateScript() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateScript() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateScript(id, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['scripts'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteScript() {
|
export function useDeleteScript() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -108,15 +147,39 @@ export function useDeleteScript() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs
|
// Env hooks
|
||||||
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) {
|
export function useEnvs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['envs'],
|
||||||
|
queryFn: api.getEnvs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEnv() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createEnv,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEnv() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.deleteEnv,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log hooks
|
||||||
|
export function useLogs(params?: Record<string, string>) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['logs', params],
|
queryKey: ['logs', params],
|
||||||
queryFn: () => api.getLogs(params),
|
queryFn: () => api.getLogs(params),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interconnect
|
// Node hooks
|
||||||
export function useNodes() {
|
export function useNodes() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['nodes'],
|
queryKey: ['nodes'],
|
||||||
@@ -141,26 +204,18 @@ export function useDeleteNode() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env
|
// Settings hooks
|
||||||
export function useEnvs() {
|
export function useSettings() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['envs'],
|
queryKey: ['settings'],
|
||||||
queryFn: api.getEnvs,
|
queryFn: api.getSettings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateEnv() {
|
export function useUpdateSettings() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.createEnv,
|
mutationFn: api.updateSettings,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteEnv() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: api.deleteEnv,
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['envs'] }),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+156
-15
@@ -2,32 +2,173 @@ const BASE_URL = (window as any).__BASE_URL__ || ''
|
|||||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||||
const API_BASE_URL = BASE_URL + API_VERSION
|
const API_BASE_URL = BASE_URL + API_VERSION
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code: number
|
||||||
|
data?: any
|
||||||
|
|
||||||
|
constructor(message: string, code: number, data?: any) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.code = code
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
code: number
|
code: number
|
||||||
msg: string
|
message: string
|
||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
const token = localStorage.getItem('token')
|
||||||
...options,
|
|
||||||
credentials: 'include',
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers
|
...((options?.headers as Record<string, string>) || {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
const json: ApiResponse<T> = await res.json()
|
// Handle non-JSON responses
|
||||||
|
const contentType = response.headers.get('content-type')
|
||||||
if (json.code === 401) {
|
if (!contentType?.includes('application/json')) {
|
||||||
window.location.href = BASE_URL + '/login'
|
if (response.status === 401) {
|
||||||
throw new Error(json.msg || 'Unauthorized')
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
throw new ApiError('Unauthorized', 401)
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(`HTTP ${response.status}`, response.status)
|
||||||
|
}
|
||||||
|
return {} as T
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json.code !== 200) {
|
const result: ApiResponse<T> = await response.json()
|
||||||
throw new Error(json.msg || 'Request failed')
|
|
||||||
|
// Business error
|
||||||
|
if (result.code !== 0 && result.code !== 200) {
|
||||||
|
throw new ApiError(result.message || 'Request failed', result.code, result.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.data
|
return result.data
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
// Network error
|
||||||
|
throw new ApiError(
|
||||||
|
error instanceof Error ? error.message : 'Network error',
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
export const login = async (username: string, password: string) => {
|
||||||
|
const data = await request<{ token: string }>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
localStorage.setItem('token', data.token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
try {
|
||||||
|
await request('/auth/logout', { method: 'POST' })
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me')
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export const getMonitorStats = () => request<any>('/monitor')
|
||||||
|
export const getTaskStats = () => request<{ total: number; running: number; scheduled: number }>('/taskstats')
|
||||||
|
export const getSentence = () => request<{ content: string; author: string }>('/sentence')
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
export const getTasks = (params?: Record<string, string>) => {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||||
|
return request<any[]>(`/tasks${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTask = (id: number) => request<any>(`/tasks/${id}`)
|
||||||
|
|
||||||
|
export const createTask = (data: any) => request<any>('/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateTask = (id: number, data: any) => request<any>(`/tasks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteTask = (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
export const executeTask = (id: number) => request<void>(`/execute/task/${id}`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const getTags = () => request<string[]>('/tasks/tags')
|
||||||
|
|
||||||
|
// Scripts
|
||||||
|
export const getScripts = () => request<any[]>('/scripts')
|
||||||
|
|
||||||
|
export const getScript = (id: number) => request<any>(`/scripts/${id}`)
|
||||||
|
|
||||||
|
export const createScript = (data: any) => request<any>('/scripts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateScript = (id: number, data: any) => request<any>(`/scripts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteScript = (id: number) => request<void>(`/scripts/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Environment Variables
|
||||||
|
export const getEnvs = () => request<any[]>('/env')
|
||||||
|
|
||||||
|
export const createEnv = (data: any) => request<any>('/env', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteEnv = (id: number) => request<void>(`/env/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
export const getLogs = (params?: Record<string, string>) => {
|
||||||
|
const query = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||||
|
return request<any[]>(`/logs${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interconnect Nodes
|
||||||
|
export const getNodes = () => request<any[]>('/interconnect/nodes')
|
||||||
|
|
||||||
|
export const createNode = (data: any) => request<any>('/interconnect/nodes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteNode = (id: string) => request<void>(`/interconnect/nodes/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
export const getSettings = () => request<any>('/settings')
|
||||||
|
export const updateSettings = (data: any) => request<any>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export { request }
|
||||||
+203
-123
@@ -1,15 +1,59 @@
|
|||||||
import { useScripts, useDeleteScript } from '@/api/hooks'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react'
|
import Editor from '@monaco-editor/react'
|
||||||
import type { Script } from '@/api/types'
|
import { useScripts, useCreateScript, useDeleteScript } from '@/api/hooks'
|
||||||
|
import { Plus, Trash2, Save, FileCode, Play, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ id: 'python', name: 'Python', extension: '.py', monaco: 'python' },
|
||||||
|
{ id: 'javascript', name: 'JavaScript', extension: '.js', monaco: 'javascript' },
|
||||||
|
{ id: 'typescript', name: 'TypeScript', extension: '.ts', monaco: 'typescript' },
|
||||||
|
{ id: 'bash', name: 'Bash', extension: '.sh', monaco: 'shell' },
|
||||||
|
{ id: 'go', name: 'Go', extension: '.go', monaco: 'go' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Scripts() {
|
export default function Scripts() {
|
||||||
const { data: scripts, isLoading, refetch } = useScripts()
|
const { data: scripts, isLoading, refetch } = useScripts()
|
||||||
|
const createScript = useCreateScript()
|
||||||
const deleteScript = useDeleteScript()
|
const deleteScript = useDeleteScript()
|
||||||
|
|
||||||
|
const [selectedScript, setSelectedScript] = useState<any>(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [language, setLanguage] = useState('python')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
|
||||||
|
const handleNewScript = () => {
|
||||||
|
setSelectedScript(null)
|
||||||
|
setCode('# New script\nprint("Hello World")')
|
||||||
|
setName('untitled')
|
||||||
|
setLanguage('python')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await createScript.mutateAsync({
|
||||||
|
name,
|
||||||
|
filename: name + LANGUAGES.find(l => l.id === language)?.extension,
|
||||||
|
content: code,
|
||||||
|
language,
|
||||||
|
})
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (confirm('Delete this script?')) {
|
if (confirm('Delete this script?')) {
|
||||||
await deleteScript.mutateAsync(id)
|
await deleteScript.mutateAsync(id)
|
||||||
|
if (selectedScript?.id === id) {
|
||||||
|
setSelectedScript(null)
|
||||||
|
setCode('')
|
||||||
}
|
}
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectScript = (script: any) => {
|
||||||
|
setSelectedScript(script)
|
||||||
|
setCode(script.content || '')
|
||||||
|
setName(script.name)
|
||||||
|
setLanguage(script.language?.toLowerCase() || 'python')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -17,150 +61,186 @@ export default function Scripts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ display: 'flex', height: 'calc(100vh - 140px)', gap: 16 }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div style={{
|
||||||
|
width: 240,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 600 }}>Scripts</h1>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => refetch()}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} strokeWidth={1.5} />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: 'var(--bg-accent)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 6,
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={14} strokeWidth={1.5} />
|
|
||||||
Add Script
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
|
|
||||||
{scripts?.map(script => (
|
|
||||||
<ScriptCard key={script.id} script={script} onDelete={() => handleDelete(script.id)} />
|
|
||||||
))}
|
|
||||||
{(!scripts || scripts.length === 0) && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
gridColumn: '1 / -1',
|
padding: 12,
|
||||||
padding: 48,
|
borderBottom: '1px solid var(--border)',
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}>
|
|
||||||
No scripts found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScriptCard({ script, onDelete }: { script: Script; onDelete: () => void }) {
|
|
||||||
const formatDate = (date: string) => {
|
|
||||||
return new Date(date).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLanguageColor = (lang: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
python: '#3776ab',
|
|
||||||
javascript: '#f7df1e',
|
|
||||||
typescript: '#3178c6',
|
|
||||||
go: '#00add8',
|
|
||||||
bash: '#4eaa25',
|
|
||||||
shell: '#4eaa25',
|
|
||||||
}
|
|
||||||
return colors[lang?.toLowerCase()] || '#6b7280'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
transition: 'border-color 0.15s',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 6,
|
|
||||||
background: `${getLanguageColor(script.language)}15`,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
}}>
|
||||||
<FileCode size={20} strokeWidth={1.5} style={{ color: getLanguageColor(script.language) }} />
|
<span style={{ fontWeight: 500, fontSize: 13 }}>Scripts</span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, marginBottom: 2 }}>{script.name}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{script.filename}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={handleNewScript}
|
||||||
title="Delete"
|
title="New script"
|
||||||
style={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'transparent',
|
background: 'var(--bg-accent)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: 'var(--text-secondary)',
|
color: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} strokeWidth={1.5} />
|
<Plus size={14} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
{/* List */}
|
||||||
<span style={{
|
<div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
|
||||||
padding: '4px 8px',
|
{scripts?.map(script => (
|
||||||
background: `${getLanguageColor(script.language)}15`,
|
<div
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => handleSelectScript(script)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: 11,
|
cursor: 'pointer',
|
||||||
color: getLanguageColor(script.language),
|
display: 'flex',
|
||||||
fontWeight: 500,
|
alignItems: 'center',
|
||||||
}}>
|
gap: 8,
|
||||||
{script.language || 'Unknown'}
|
background: selectedScript?.id === script.id ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileCode size={14} strokeWidth={1.5} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{script.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{(!scripts || scripts.length === 0) && (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
No scripts
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
{/* Editor */}
|
||||||
Updated: {formatDate(script.updated_at)}
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Script name"
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 13,
|
||||||
|
width: 200,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map(lang => (
|
||||||
|
<option key={lang.id} value={lang.id}>{lang.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{selectedScript && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(selectedScript.id)}
|
||||||
|
title="Delete"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} strokeWidth={1.5} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'var(--bg-accent)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save size={12} strokeWidth={1.5} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monaco Editor */}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={LANGUAGES.find(l => l.id === language)?.monaco || 'python'}
|
||||||
|
value={code}
|
||||||
|
onChange={(value) => setCode(value || '')}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'JetBrains Mono, Menlo, monospace',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
padding: { top: 12 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user