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:
+2
-1
@@ -9,11 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"@tanstack/react-table": "^8.21.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.2.7",
|
||||
|
||||
Generated
+57
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
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':
|
||||
specifier: ^5.80.0
|
||||
version: 5.101.2(react@19.2.7)
|
||||
@@ -85,6 +88,16 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
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':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
@@ -276,6 +289,9 @@ packages:
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vitejs/plugin-react@6.0.3':
|
||||
resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -381,6 +397,9 @@ packages:
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
electron-to-chromium@1.5.393:
|
||||
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
||||
|
||||
@@ -523,6 +542,14 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
||||
|
||||
@@ -619,6 +646,9 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
tailwindcss@4.3.3:
|
||||
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
|
||||
|
||||
@@ -735,6 +765,17 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
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)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
@@ -878,6 +919,9 @@ snapshots:
|
||||
dependencies:
|
||||
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))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
@@ -963,6 +1007,10 @@ snapshots:
|
||||
'@babel/runtime': 7.29.7
|
||||
csstype: 3.2.3
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
electron-to-chromium@1.5.393: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
@@ -1054,6 +1102,13 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
motion-utils: 11.18.1
|
||||
@@ -1160,6 +1215,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
+84
-29
@@ -1,21 +1,40 @@
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: api.getCurrentUser,
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: ['monitor'],
|
||||
queryFn: api.getMonitor,
|
||||
queryFn: api.getMonitorStats,
|
||||
refetchInterval: 5000,
|
||||
retry: 2,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,8 +46,8 @@ export function useTaskStats() {
|
||||
})
|
||||
}
|
||||
|
||||
// Tasks
|
||||
export function useTasks(params?: { status?: string; tag?: string }) {
|
||||
// Task hooks
|
||||
export function useTasks(params?: Record<string, string>) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', params],
|
||||
queryFn: () => api.getTasks(params),
|
||||
@@ -47,15 +66,17 @@ export function useCreateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: api.createTask,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['tasks'] })
|
||||
qc.invalidateQueries({ queryKey: ['taskStats'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Parameters<typeof api.updateTask>[1] }) =>
|
||||
api.updateTask(id, data),
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) => api.updateTask(id, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
})
|
||||
}
|
||||
@@ -64,7 +85,10 @@ export function useDeleteTask() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
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() {
|
||||
return useQuery({
|
||||
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() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -108,15 +147,39 @@ export function useDeleteScript() {
|
||||
})
|
||||
}
|
||||
|
||||
// Logs
|
||||
export function useLogs(params?: { task_id?: number; status?: string; page?: number }) {
|
||||
// Env hooks
|
||||
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({
|
||||
queryKey: ['logs', params],
|
||||
queryFn: () => api.getLogs(params),
|
||||
})
|
||||
}
|
||||
|
||||
// Interconnect
|
||||
// Node hooks
|
||||
export function useNodes() {
|
||||
return useQuery({
|
||||
queryKey: ['nodes'],
|
||||
@@ -141,26 +204,18 @@ export function useDeleteNode() {
|
||||
})
|
||||
}
|
||||
|
||||
// Env
|
||||
export function useEnvs() {
|
||||
// Settings hooks
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['envs'],
|
||||
queryFn: api.getEnvs,
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEnv() {
|
||||
export function useUpdateSettings() {
|
||||
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'] }),
|
||||
mutationFn: api.updateSettings,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
})
|
||||
}
|
||||
+159
-18
@@ -2,32 +2,173 @@ const BASE_URL = (window as any).__BASE_URL__ || ''
|
||||
const API_VERSION = (window as any).__API_VERSION__ || '/api/v1'
|
||||
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> {
|
||||
code: number
|
||||
msg: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Handle non-JSON responses
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType?.includes('application/json')) {
|
||||
if (response.status === 401) {
|
||||
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
|
||||
}
|
||||
|
||||
const result: ApiResponse<T> = await response.json()
|
||||
|
||||
// Business error
|
||||
if (result.code !== 0 && result.code !== 200) {
|
||||
throw new ApiError(result.message || 'Request failed', result.code, result.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
|
||||
}
|
||||
|
||||
const json: ApiResponse<T> = await res.json()
|
||||
|
||||
if (json.code === 401) {
|
||||
window.location.href = BASE_URL + '/login'
|
||||
throw new Error(json.msg || 'Unauthorized')
|
||||
export const logout = async () => {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
if (json.code !== 200) {
|
||||
throw new Error(json.msg || 'Request failed')
|
||||
}
|
||||
export const getCurrentUser = () => request<{ id: number; username: string; role: string }>('/auth/me')
|
||||
|
||||
return json.data
|
||||
}
|
||||
// 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 }
|
||||
+208
-128
@@ -1,166 +1,246 @@
|
||||
import { useScripts, useDeleteScript } from '@/api/hooks'
|
||||
import { Plus, Trash2, FileCode, RefreshCw } from 'lucide-react'
|
||||
import type { Script } from '@/api/types'
|
||||
import { useState } from 'react'
|
||||
import Editor from '@monaco-editor/react'
|
||||
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() {
|
||||
const { data: scripts, isLoading, refetch } = useScripts()
|
||||
const createScript = useCreateScript()
|
||||
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) => {
|
||||
if (confirm('Delete this script?')) {
|
||||
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) {
|
||||
return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 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 }}>
|
||||
<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 */}
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontWeight: 500, fontSize: 13 }}>Scripts</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
onClick={handleNewScript}
|
||||
title="New script"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
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',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-accent)',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
borderRadius: 4,
|
||||
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={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: 48,
|
||||
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',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<FileCode size={20} strokeWidth={1.5} style={{ color: getLanguageColor(script.language) }} />
|
||||
</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>
|
||||
{/* List */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 8 }}>
|
||||
{scripts?.map(script => (
|
||||
<div
|
||||
key={script.id}
|
||||
onClick={() => handleSelectScript(script)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
{(!scripts || scripts.length === 0) && (
|
||||
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
No scripts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
background: `${getLanguageColor(script.language)}15`,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: getLanguageColor(script.language),
|
||||
fontWeight: 500,
|
||||
{/* Editor */}
|
||||
<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,
|
||||
}}>
|
||||
{script.language || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Updated: {formatDate(script.updated_at)}
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user