@kurly-growth/growthman 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/CLAUDE.md +58 -0
- package/README.md +23 -0
- package/app/api/endpoints/[id]/route.ts +63 -0
- package/app/api/endpoints/bulk/route.ts +23 -0
- package/app/api/endpoints/import/route.ts +52 -0
- package/app/api/endpoints/route.ts +39 -0
- package/app/api/endpoints/sync/route.ts +80 -0
- package/app/api/mock/[...slug]/route.ts +82 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +125 -0
- package/app/layout.tsx +36 -0
- package/app/page.tsx +184 -0
- package/bin/cli.js +28 -0
- package/components/endpoint-edit-dialog.tsx +181 -0
- package/components/endpoint-table.tsx +133 -0
- package/components/openapi-upload-dialog.tsx +196 -0
- package/components/ui/button.tsx +62 -0
- package/components/ui/checkbox.tsx +32 -0
- package/components/ui/dialog.tsx +143 -0
- package/components/ui/input.tsx +21 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/sonner.tsx +37 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/textarea.tsx +18 -0
- package/components.json +22 -0
- package/eslint.config.mjs +18 -0
- package/lib/openapi-parser.ts +270 -0
- package/lib/prisma.ts +9 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.ts +19 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +4 -0
- package/postcss.config.mjs +7 -0
- package/prisma/schema.prisma +23 -0
- package/prisma/seed.ts +29 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/endpoint.ts +10 -0
package/app/page.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
import { MockEndpoint } from '@/types/endpoint'
|
|
5
|
+
import { EndpointTable } from '@/components/endpoint-table'
|
|
6
|
+
import { EndpointEditDialog } from '@/components/endpoint-edit-dialog'
|
|
7
|
+
import { OpenAPIUploadDialog } from '@/components/openapi-upload-dialog'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import { toast } from 'sonner'
|
|
10
|
+
|
|
11
|
+
export default function Home() {
|
|
12
|
+
const [endpoints, setEndpoints] = useState<MockEndpoint[]>([])
|
|
13
|
+
const [loading, setLoading] = useState(true)
|
|
14
|
+
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
|
15
|
+
const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
|
|
16
|
+
const [selectedEndpoint, setSelectedEndpoint] = useState<MockEndpoint | null>(null)
|
|
17
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
18
|
+
|
|
19
|
+
const fetchEndpoints = useCallback(async () => {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch('/api/endpoints')
|
|
22
|
+
const data = await response.json()
|
|
23
|
+
setEndpoints(data)
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('Failed to fetch endpoints:', error)
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false)
|
|
28
|
+
}
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetchEndpoints()
|
|
33
|
+
}, [fetchEndpoints])
|
|
34
|
+
|
|
35
|
+
const handleEdit = (endpoint: MockEndpoint) => {
|
|
36
|
+
setSelectedEndpoint(endpoint)
|
|
37
|
+
setEditDialogOpen(true)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handleCreate = () => {
|
|
41
|
+
setSelectedEndpoint(null)
|
|
42
|
+
setEditDialogOpen(true)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handleDelete = async (id: string) => {
|
|
46
|
+
if (!confirm('Are you sure you want to delete this endpoint?')) return
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`/api/endpoints/${id}`, { method: 'DELETE' })
|
|
50
|
+
if (!response.ok) throw new Error('Failed to delete')
|
|
51
|
+
setSelectedIds((prev) => {
|
|
52
|
+
const newSet = new Set(prev)
|
|
53
|
+
newSet.delete(id)
|
|
54
|
+
return newSet
|
|
55
|
+
})
|
|
56
|
+
toast.success('Endpoint deleted successfully')
|
|
57
|
+
fetchEndpoints()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to delete endpoint:', error)
|
|
60
|
+
toast.error('Failed to delete endpoint')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handleBulkDelete = async () => {
|
|
65
|
+
if (selectedIds.size === 0) return
|
|
66
|
+
if (!confirm(`Are you sure you want to delete ${selectedIds.size} endpoint(s)?`)) return
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch('/api/endpoints/bulk', {
|
|
70
|
+
method: 'DELETE',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
|
73
|
+
})
|
|
74
|
+
if (!response.ok) throw new Error('Failed to delete')
|
|
75
|
+
const count = selectedIds.size
|
|
76
|
+
setSelectedIds(new Set())
|
|
77
|
+
toast.success(`${count} endpoint(s) deleted successfully`)
|
|
78
|
+
fetchEndpoints()
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error('Failed to delete endpoints:', error)
|
|
81
|
+
toast.error('Failed to delete endpoints')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleSave = async (endpoint: Partial<MockEndpoint> & { id?: string }) => {
|
|
86
|
+
try {
|
|
87
|
+
const isUpdate = !!endpoint.id
|
|
88
|
+
const response = isUpdate
|
|
89
|
+
? await fetch(`/api/endpoints/${endpoint.id}`, {
|
|
90
|
+
method: 'PUT',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify(endpoint),
|
|
93
|
+
})
|
|
94
|
+
: await fetch('/api/endpoints', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify(endpoint),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const data = await response.json()
|
|
102
|
+
throw new Error(data.error || 'Failed to save')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
toast.success(isUpdate ? 'Endpoint updated successfully' : 'Endpoint created successfully')
|
|
106
|
+
fetchEndpoints()
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Failed to save endpoint:', error)
|
|
109
|
+
toast.error(error instanceof Error ? error.message : 'Failed to save endpoint')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="min-h-screen bg-gray-50">
|
|
115
|
+
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
|
116
|
+
<div className="flex justify-between items-center mb-8">
|
|
117
|
+
<div>
|
|
118
|
+
<h1 className="text-3xl font-bold text-gray-900">Growthman</h1>
|
|
119
|
+
<p className="mt-1 text-gray-500">
|
|
120
|
+
Manage your mock API endpoints
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex gap-3">
|
|
124
|
+
<Button variant="outline" onClick={() => setUploadDialogOpen(true)}>
|
|
125
|
+
Import OpenAPI
|
|
126
|
+
</Button>
|
|
127
|
+
<Button onClick={handleCreate}>
|
|
128
|
+
+ New Endpoint
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{selectedIds.size > 0 && (
|
|
134
|
+
<div className="mb-4 flex items-center gap-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
135
|
+
<span className="text-sm text-blue-800">
|
|
136
|
+
{selectedIds.size} endpoint(s) selected
|
|
137
|
+
</span>
|
|
138
|
+
<Button
|
|
139
|
+
variant="destructive"
|
|
140
|
+
size="sm"
|
|
141
|
+
onClick={handleBulkDelete}
|
|
142
|
+
>
|
|
143
|
+
Delete Selected
|
|
144
|
+
</Button>
|
|
145
|
+
<Button
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="sm"
|
|
148
|
+
onClick={() => setSelectedIds(new Set())}
|
|
149
|
+
>
|
|
150
|
+
Clear Selection
|
|
151
|
+
</Button>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="bg-white shadow rounded-lg">
|
|
156
|
+
{loading ? (
|
|
157
|
+
<div className="p-8 text-center text-gray-500">Loading...</div>
|
|
158
|
+
) : (
|
|
159
|
+
<EndpointTable
|
|
160
|
+
endpoints={endpoints}
|
|
161
|
+
onEdit={handleEdit}
|
|
162
|
+
onDelete={handleDelete}
|
|
163
|
+
selectedIds={selectedIds}
|
|
164
|
+
onSelectionChange={setSelectedIds}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<EndpointEditDialog
|
|
171
|
+
endpoint={selectedEndpoint}
|
|
172
|
+
open={editDialogOpen}
|
|
173
|
+
onOpenChange={setEditDialogOpen}
|
|
174
|
+
onSave={handleSave}
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
<OpenAPIUploadDialog
|
|
178
|
+
open={uploadDialogOpen}
|
|
179
|
+
onOpenChange={setUploadDialogOpen}
|
|
180
|
+
onImportComplete={fetchEndpoints}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync, spawn } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const packageDir = path.resolve(__dirname, '..');
|
|
8
|
+
const dbPath = path.join(packageDir, 'prisma', 'dev.db');
|
|
9
|
+
|
|
10
|
+
// DB가 없으면 생성
|
|
11
|
+
if (!fs.existsSync(dbPath)) {
|
|
12
|
+
console.log('Initializing database...');
|
|
13
|
+
execSync('npx prisma generate && npx prisma db push', {
|
|
14
|
+
cwd: packageDir,
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 서버 시작
|
|
20
|
+
console.log('Starting mock server at http://localhost:3000');
|
|
21
|
+
const server = spawn('npx', ['next', 'start'], {
|
|
22
|
+
cwd: packageDir,
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
server.on('close', (code) => {
|
|
27
|
+
process.exit(code);
|
|
28
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import dynamic from 'next/dynamic'
|
|
5
|
+
import { MockEndpoint } from '@/types/endpoint'
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
} from '@/components/ui/dialog'
|
|
13
|
+
import { Button } from '@/components/ui/button'
|
|
14
|
+
import { Input } from '@/components/ui/input'
|
|
15
|
+
import { Label } from '@/components/ui/label'
|
|
16
|
+
|
|
17
|
+
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
|
|
18
|
+
ssr: false,
|
|
19
|
+
loading: () => <div className="h-[400px] bg-gray-100 animate-pulse rounded" />,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
interface EndpointEditDialogProps {
|
|
23
|
+
endpoint: MockEndpoint | null
|
|
24
|
+
open: boolean
|
|
25
|
+
onOpenChange: (open: boolean) => void
|
|
26
|
+
onSave: (endpoint: Partial<MockEndpoint> & { id?: string }) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
|
30
|
+
|
|
31
|
+
export function EndpointEditDialog({
|
|
32
|
+
endpoint,
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
onSave,
|
|
36
|
+
}: EndpointEditDialogProps) {
|
|
37
|
+
const [path, setPath] = useState('')
|
|
38
|
+
const [method, setMethod] = useState('GET')
|
|
39
|
+
const [description, setDescription] = useState('')
|
|
40
|
+
const [statusCode, setStatusCode] = useState(200)
|
|
41
|
+
const [responseBody, setResponseBody] = useState('{}')
|
|
42
|
+
const [jsonError, setJsonError] = useState<string | null>(null)
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (endpoint) {
|
|
46
|
+
setPath(endpoint.path)
|
|
47
|
+
setMethod(endpoint.method)
|
|
48
|
+
setDescription(endpoint.description || '')
|
|
49
|
+
setStatusCode(endpoint.statusCode)
|
|
50
|
+
setResponseBody(JSON.stringify(endpoint.responseBody, null, 2))
|
|
51
|
+
} else {
|
|
52
|
+
setPath('')
|
|
53
|
+
setMethod('GET')
|
|
54
|
+
setDescription('')
|
|
55
|
+
setStatusCode(200)
|
|
56
|
+
setResponseBody('{}')
|
|
57
|
+
}
|
|
58
|
+
setJsonError(null)
|
|
59
|
+
}, [endpoint, open])
|
|
60
|
+
|
|
61
|
+
const handleSave = () => {
|
|
62
|
+
try {
|
|
63
|
+
const parsedBody = JSON.parse(responseBody)
|
|
64
|
+
setJsonError(null)
|
|
65
|
+
onSave({
|
|
66
|
+
id: endpoint?.id,
|
|
67
|
+
path,
|
|
68
|
+
method,
|
|
69
|
+
description: description || null,
|
|
70
|
+
statusCode,
|
|
71
|
+
responseBody: parsedBody,
|
|
72
|
+
})
|
|
73
|
+
onOpenChange(false)
|
|
74
|
+
} catch {
|
|
75
|
+
setJsonError('Invalid JSON format')
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
81
|
+
<DialogContent className="sm:max-w-[70vw] h-[90vh] flex flex-col">
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>
|
|
84
|
+
{endpoint ? 'Edit Endpoint' : 'Create New Endpoint'}
|
|
85
|
+
</DialogTitle>
|
|
86
|
+
</DialogHeader>
|
|
87
|
+
|
|
88
|
+
<div className="grid gap-4 py-4 flex-1 overflow-hidden">
|
|
89
|
+
<div className="flex items-center gap-4">
|
|
90
|
+
<Label htmlFor="method" className="w-28 text-right shrink-0">
|
|
91
|
+
Method
|
|
92
|
+
</Label>
|
|
93
|
+
<select
|
|
94
|
+
id="method"
|
|
95
|
+
value={method}
|
|
96
|
+
onChange={(e) => setMethod(e.target.value)}
|
|
97
|
+
className="flex-1 h-10 rounded-md border border-input bg-background px-3 py-2"
|
|
98
|
+
>
|
|
99
|
+
{HTTP_METHODS.map((m) => (
|
|
100
|
+
<option key={m} value={m}>
|
|
101
|
+
{m}
|
|
102
|
+
</option>
|
|
103
|
+
))}
|
|
104
|
+
</select>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="flex items-center gap-4">
|
|
108
|
+
<Label htmlFor="path" className="w-28 text-right shrink-0">
|
|
109
|
+
Path
|
|
110
|
+
</Label>
|
|
111
|
+
<Input
|
|
112
|
+
id="path"
|
|
113
|
+
value={path}
|
|
114
|
+
onChange={(e) => setPath(e.target.value)}
|
|
115
|
+
placeholder="/v1/users/:userId"
|
|
116
|
+
className="flex-1"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="flex items-center gap-4">
|
|
121
|
+
<Label htmlFor="description" className="w-28 text-right shrink-0">
|
|
122
|
+
Description
|
|
123
|
+
</Label>
|
|
124
|
+
<Input
|
|
125
|
+
id="description"
|
|
126
|
+
value={description}
|
|
127
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
128
|
+
placeholder="API description"
|
|
129
|
+
className="flex-1"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="flex items-center gap-4">
|
|
134
|
+
<Label htmlFor="statusCode" className="w-28 text-right shrink-0">
|
|
135
|
+
Status Code
|
|
136
|
+
</Label>
|
|
137
|
+
<Input
|
|
138
|
+
id="statusCode"
|
|
139
|
+
type="number"
|
|
140
|
+
value={statusCode}
|
|
141
|
+
onChange={(e) => setStatusCode(Number(e.target.value))}
|
|
142
|
+
className="flex-1"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="flex items-start gap-4">
|
|
147
|
+
<Label className="w-28 text-right shrink-0 pt-2">Response Body</Label>
|
|
148
|
+
<div className="flex-1">
|
|
149
|
+
<div className="border rounded-md overflow-hidden">
|
|
150
|
+
<MonacoEditor
|
|
151
|
+
height="calc(85vh - 320px)"
|
|
152
|
+
language="json"
|
|
153
|
+
theme="vs-dark"
|
|
154
|
+
value={responseBody}
|
|
155
|
+
onChange={(value) => setResponseBody(value || '{}')}
|
|
156
|
+
options={{
|
|
157
|
+
minimap: { enabled: false },
|
|
158
|
+
fontSize: 14,
|
|
159
|
+
formatOnPaste: true,
|
|
160
|
+
automaticLayout: true,
|
|
161
|
+
scrollBeyondLastLine: false,
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
{jsonError && (
|
|
166
|
+
<p className="text-red-500 text-sm mt-2">{jsonError}</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<DialogFooter className="shrink-0">
|
|
173
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
174
|
+
Cancel
|
|
175
|
+
</Button>
|
|
176
|
+
<Button onClick={handleSave}>Save</Button>
|
|
177
|
+
</DialogFooter>
|
|
178
|
+
</DialogContent>
|
|
179
|
+
</Dialog>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { MockEndpoint } from '@/types/endpoint'
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableCell,
|
|
8
|
+
TableHead,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableRow,
|
|
11
|
+
} from '@/components/ui/table'
|
|
12
|
+
import { Button } from '@/components/ui/button'
|
|
13
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
14
|
+
|
|
15
|
+
interface EndpointTableProps {
|
|
16
|
+
endpoints: MockEndpoint[]
|
|
17
|
+
onEdit: (endpoint: MockEndpoint) => void
|
|
18
|
+
onDelete: (id: string) => void
|
|
19
|
+
selectedIds: Set<string>
|
|
20
|
+
onSelectionChange: (ids: Set<string>) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const methodColors: Record<string, string> = {
|
|
24
|
+
GET: 'bg-green-100 text-green-800',
|
|
25
|
+
POST: 'bg-blue-100 text-blue-800',
|
|
26
|
+
PUT: 'bg-yellow-100 text-yellow-800',
|
|
27
|
+
PATCH: 'bg-orange-100 text-orange-800',
|
|
28
|
+
DELETE: 'bg-red-100 text-red-800',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function EndpointTable({
|
|
32
|
+
endpoints,
|
|
33
|
+
onEdit,
|
|
34
|
+
onDelete,
|
|
35
|
+
selectedIds,
|
|
36
|
+
onSelectionChange,
|
|
37
|
+
}: EndpointTableProps) {
|
|
38
|
+
const allSelected = endpoints.length > 0 && endpoints.every((e) => selectedIds.has(e.id))
|
|
39
|
+
const someSelected = endpoints.some((e) => selectedIds.has(e.id)) && !allSelected
|
|
40
|
+
|
|
41
|
+
const handleSelectAll = (checked: boolean) => {
|
|
42
|
+
if (checked) {
|
|
43
|
+
onSelectionChange(new Set(endpoints.map((e) => e.id)))
|
|
44
|
+
} else {
|
|
45
|
+
onSelectionChange(new Set())
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handleSelectOne = (id: string, checked: boolean) => {
|
|
50
|
+
const newSet = new Set(selectedIds)
|
|
51
|
+
if (checked) {
|
|
52
|
+
newSet.add(id)
|
|
53
|
+
} else {
|
|
54
|
+
newSet.delete(id)
|
|
55
|
+
}
|
|
56
|
+
onSelectionChange(newSet)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Table>
|
|
61
|
+
<TableHeader>
|
|
62
|
+
<TableRow>
|
|
63
|
+
<TableHead className="w-[50px]">
|
|
64
|
+
<Checkbox
|
|
65
|
+
checked={allSelected}
|
|
66
|
+
ref={(el) => {
|
|
67
|
+
if (el) {
|
|
68
|
+
(el as unknown as HTMLInputElement).indeterminate = someSelected
|
|
69
|
+
}
|
|
70
|
+
}}
|
|
71
|
+
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
|
72
|
+
aria-label="Select all"
|
|
73
|
+
/>
|
|
74
|
+
</TableHead>
|
|
75
|
+
<TableHead className="w-[100px]">Method</TableHead>
|
|
76
|
+
<TableHead>Path</TableHead>
|
|
77
|
+
<TableHead>Description</TableHead>
|
|
78
|
+
<TableHead className="w-[150px]">Actions</TableHead>
|
|
79
|
+
</TableRow>
|
|
80
|
+
</TableHeader>
|
|
81
|
+
<TableBody>
|
|
82
|
+
{endpoints.length === 0 ? (
|
|
83
|
+
<TableRow>
|
|
84
|
+
<TableCell colSpan={5} className="text-center text-gray-500">
|
|
85
|
+
No endpoints registered yet
|
|
86
|
+
</TableCell>
|
|
87
|
+
</TableRow>
|
|
88
|
+
) : (
|
|
89
|
+
endpoints.map((endpoint) => (
|
|
90
|
+
<TableRow key={endpoint.id}>
|
|
91
|
+
<TableCell>
|
|
92
|
+
<Checkbox
|
|
93
|
+
checked={selectedIds.has(endpoint.id)}
|
|
94
|
+
onCheckedChange={(checked) => handleSelectOne(endpoint.id, checked === true)}
|
|
95
|
+
aria-label={`Select ${endpoint.path}`}
|
|
96
|
+
/>
|
|
97
|
+
</TableCell>
|
|
98
|
+
<TableCell>
|
|
99
|
+
<span
|
|
100
|
+
className={`px-2 py-1 rounded text-xs font-medium ${methodColors[endpoint.method] || 'bg-gray-100'}`}
|
|
101
|
+
>
|
|
102
|
+
{endpoint.method}
|
|
103
|
+
</span>
|
|
104
|
+
</TableCell>
|
|
105
|
+
<TableCell className="font-mono text-sm">{endpoint.path}</TableCell>
|
|
106
|
+
<TableCell className="text-gray-600">
|
|
107
|
+
{endpoint.description || '-'}
|
|
108
|
+
</TableCell>
|
|
109
|
+
<TableCell>
|
|
110
|
+
<div className="flex gap-2">
|
|
111
|
+
<Button
|
|
112
|
+
variant="outline"
|
|
113
|
+
size="sm"
|
|
114
|
+
onClick={() => onEdit(endpoint)}
|
|
115
|
+
>
|
|
116
|
+
Edit
|
|
117
|
+
</Button>
|
|
118
|
+
<Button
|
|
119
|
+
variant="destructive"
|
|
120
|
+
size="sm"
|
|
121
|
+
onClick={() => onDelete(endpoint.id)}
|
|
122
|
+
>
|
|
123
|
+
Delete
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
</TableCell>
|
|
127
|
+
</TableRow>
|
|
128
|
+
))
|
|
129
|
+
)}
|
|
130
|
+
</TableBody>
|
|
131
|
+
</Table>
|
|
132
|
+
)
|
|
133
|
+
}
|