TCP 서버 모드 추가

This commit is contained in:
현성필
2025-01-21 13:19:21 +09:00
parent e23684d833
commit cb9201ddd2
106 changed files with 17710 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
import React, {useState} from 'react';
import {Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSeparator, MenubarTrigger} from '@/components/ui/menubar';
import MainContentArea from '@/components/MainContentArea.jsx';
import {useAPI} from '@/providers/APIProvider.jsx';
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.jsx';
const menuConfig = [
{
id: 'response',
label: '응답관리',
items: [
{id: 'response-list', label: '응답목록', action: 'response-list'},
{id: 'response-add', label: '응답추가', action: 'response-add'}
]
},
{
id: 'request',
label: '요청관리',
items: [
{id: 'collection-add', label: '컬렉션 추가', action: 'collection-add'},
{id: 'request-add', label: '요청 추가', action: 'request-add'},
{id: 'scenario-add', label: '시나리오 추가', action: 'scenario-add'}
]
},
{
id: 'queue',
label: '큐관리',
items: [
{id: 'queue-status', label: '큐상태', action: 'queue-status'},
{id: 'queue-settings', label: '큐설정', action: 'queue-settings'}
]
},
{
id: 'loadtest',
label: '부하테스트',
items: [
{id: 'load-test-new', label: '새 테스트', action: 'load-test-new'},
{id: 'load-test-history', label: '테스트 이력', action: 'load-test-history'},
{id: 'load-test-reports', label: '결과 리포트', action: 'load-test-reports'}
]
},
{
id: 'system',
label: '시스템관리',
items: [
{id: 'system-settings', label: '시스템설정', action: 'system-settings'},
{id: 'user-management', label: '사용자관리', action: 'user-management'},
{type: 'separator'},
{id: 'system-logs', label: '시스템로그', action: 'system-logs'}
]
},
{
id: 'account',
label: '계정',
items: [
{id: 'change-password', label: '비밀번호변경', action: 'change-password'},
{type: 'separator'},
{id: 'logout', label: '로그아웃', action: 'logout'}
]
}
];
const MenuItem = ({item, onMenuClick}) => {
if (item.type === 'separator') {
return <MenubarSeparator/>;
}
return (
<MenubarItem onClick={() => onMenuClick(item.action)}>
{item.label}
</MenubarItem>
);
};
const APITester = () => {
// API 모드와 시나리오 모드 상태를 관리
const [isApiMode, setIsApiMode] = useState(true);
const [showAddCollection, setShowAddCollection] = useState(false);
const [showAddApi, setShowAddApi] = useState(false);
const { handleLogout, createCollection, createAPIRequest } = useAPI();
// 모드 전환 핸들러
const toggleMode = () => {
setIsApiMode((prevMode) => !prevMode);
};
const handleAddCollection = async (name) => {
try {
await createCollection(name);
return true;
} catch (error) {
throw new Error('컬렉션 생성에 실패했습니다');
}
};
const handleAddApi = async (collectionId, apiData) => {
try {
await createAPIRequest(collectionId, apiData);
return true;
} catch (error) {
console.error('Failed to create API:', error);
return false;
}
};
const handleMenuClick = async (action) => {
// Handle menu item clicks
console.log('Menu action:', action);
switch (action) {
case 'request-add':
setShowAddApi(true);
break;
case 'collection-add':
setShowAddCollection(true);
break;
case 'logout':
const success = await handleLogout();
if (success) {
window.location.href = "/actionLogout.do";
}
break;
case 'change-password':
// Handle password change
break;
// Add other cases as needed
}
};
return (
<div className="flex flex-col h-screen">
{/* Fixed Menubar */}
<div className="fixed top-0 left-0 right-0 z-50 bg-background border-b">
<div className="flex items-center h-14">
<div className="px-4 py-2 border-r h-full flex items-center">
<img
src="/img/logo.png"
alt="testmaster"
className="h-8 w-auto"
/>
</div>
<Menubar className="border-none flex-1">
{menuConfig.map((menu) => (
<MenubarMenu key={menu.id}>
<MenubarTrigger className="font-bold">
{menu.label}
</MenubarTrigger>
<MenubarContent>
{menu.items.map((item, index) => (
<MenuItem
key={item.id || `separator-${index}`}
item={item}
onMenuClick={handleMenuClick}
/>
))}
</MenubarContent>
</MenubarMenu>
))}
</Menubar>
</div>
</div>
{/* Main content area with top padding for menubar */}
<div className="flex-1 pt-14">
<MainContentArea isApiMode={isApiMode} toggleMode={toggleMode}/>
</div>
<AddCollectionDialog
open={showAddCollection}
onOpenChange={setShowAddCollection}
onSubmit={handleAddCollection}
/>
{/* Add these at the bottom of your return statement */}
<AddAPIDialog
open={showAddApi}
onOpenChange={setShowAddApi}
onSubmit={handleAddApi}
/>
</div>
);
};
export default APITester;
@@ -0,0 +1,294 @@
import React, {useState, useEffect} from 'react';
import {Button} from '@/components/ui/button';
import {Alert, AlertDescription} from '@/components/ui/alert';
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import LoadTester from './LoadTester';
import LoadTestResult from './LoadTestResult';
const LoadTestManager = ({contextPath = '/'}) => {
const [tests, setTests] = useState([]);
const [scenarios, setScenarios] = useState([]);
const [currentTest, setCurrentTest] = useState(null);
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState({show: false, message: ''});
const [newTestDialog, setNewTestDialog] = useState(false);
const [deleteDialog, setDeleteDialog] = useState({show: false, id: null});
const [newTestName, setNewTestName] = useState('');
useEffect(() => {
loadScenarioList();
loadTestList();
}, []);
const showToast = (message) => {
setToast({show: true, message});
setTimeout(() => setToast({show: false, message: ''}), 3000);
};
const loadScenarioList = async () => {
try {
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
}
});
const data = await response.json();
setScenarios(data);
} catch (error) {
showToast(error.message);
}
};
const loadTestList = async () => {
setLoading(true);
try {
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
}
});
const data = await response.json();
setTests(data);
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
}
};
const handleCreateTest = async () => {
if (!newTestName) {
return;
}
setLoading(true);
try {
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
},
body: JSON.stringify({name: newTestName})
});
if (!response.ok) {
throw new Error('Failed to create test');
}
await loadTestList();
setNewTestDialog(false);
setNewTestName('');
showToast('Test created successfully');
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
}
};
const handleDeleteTest = async () => {
if (!deleteDialog.id) {
return;
}
setLoading(true);
try {
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
},
body: JSON.stringify({id: deleteDialog.id})
});
if (!response.ok) {
throw new Error('Failed to delete test');
}
await loadTestList();
setDeleteDialog({show: false, id: null});
showToast('Test deleted successfully');
if (currentTest?.id === deleteDialog.id) {
setCurrentTest(null);
}
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
}
};
const handleSaveTest = async () => {
if (!currentTest) {
return;
}
setLoading(true);
try {
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
},
body: JSON.stringify(currentTest)
});
if (!response.ok) {
throw new Error('Failed to save test');
}
showToast('Test saved successfully');
} catch (error) {
showToast(error.message);
} finally {
setLoading(false);
}
};
return (
<div className="flex h-full">
{/* Sidebar */}
<div className="w-64 border-r p-4 bg-background">
<div className="space-y-4">
<Button
className="w-full"
onClick={() => setNewTestDialog(true)}
>
New Load Test
</Button>
<div className="space-y-2">
{tests.map(test => (
<div
key={test.id}
className={`flex justify-between items-center p-2 rounded hover:bg-accent ${
currentTest?.id === test.id ? 'bg-accent' : ''
}`}
>
<button
className="flex-1 text-left"
onClick={() => setCurrentTest(test)}
>
{test.name}
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteDialog({show: true, id: test.id})}
>
<i className="fa fa-trash-can"/>
</Button>
</div>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 p-4">
{currentTest ? (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">{currentTest.name}</h2>
<div className="space-x-2">
<Button onClick={handleSaveTest}>Save</Button>
<Button variant="outline" onClick={() => setCurrentTest(null)}>Close</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<LoadTester
contextPath={contextPath}
scenarios={scenarios}
test={currentTest}
onTestUpdate={setCurrentTest}
/>
</div>
<div className="border rounded-lg p-4">
<LoadTestResult
contextPath={contextPath}
testId={currentTest.id}
/>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a test to begin
</div>
)}
</div>
{/* New Test Dialog */}
<Dialog open={newTestDialog} onOpenChange={setNewTestDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Load Test</DialogTitle>
</DialogHeader>
<Input
value={newTestName}
onChange={(e) => setNewTestName(e.target.value)}
placeholder="Test name"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewTestDialog(false)}>
Cancel
</Button>
<Button onClick={handleCreateTest}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialog.show}
onOpenChange={(open) => setDeleteDialog({...deleteDialog, show: open})}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Test</DialogTitle>
</DialogHeader>
<p>Are you sure you want to delete this test?</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialog({show: false, id: null})}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteTest}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Toast */}
{toast.show && (
<Alert className="fixed bottom-4 right-4 max-w-md">
<AlertDescription>{toast.message}</AlertDescription>
</Alert>
)}
{/* Loading Overlay */}
{loading && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-primary"></div>
</div>
)}
</div>
);
};
export default LoadTestManager;
@@ -0,0 +1,169 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { useMonaco } from '@monaco-editor/react';
const LoadTestResult = ({ contextPath = '/', testId }) => {
const [results, setResults] = useState([]);
const [selectedResult, setSelectedResult] = useState(null);
const [activeTab, setActiveTab] = useState('body');
const stompClientRef = useRef(null);
const monaco = useMonaco();
const editorRef = useRef(null);
useEffect(() => {
// Initialize Monaco editor
if (monaco && !editorRef.current) {
editorRef.current = monaco.editor.create(document.getElementById('test-output'), {
value: '',
language: 'json',
automaticLayout: true,
readOnly: true,
minimap: { enabled: false },
theme: 'vs-dark'
});
}
return () => {
if (editorRef.current) {
editorRef.current.dispose();
editorRef.current = null;
}
};
}, [monaco]);
useEffect(() => {
if (!testId) return;
// Initialize WebSocket connection
const socket = new SockJS(`${contextPath}load-test-result`);
const client = new Client({
webSocketFactory: () => socket,
onConnect: () => {
client.subscribe(`/topic/${testId}`, (message) => {
const result = JSON.parse(message.body);
setResults(prev => [...prev, result]);
});
},
onStompError: (error) => {
console.error('STOMP error:', error);
}
});
stompClientRef.current = client;
client.activate();
return () => {
if (client.connected) {
client.deactivate();
}
};
}, [testId, contextPath]);
const reset = useCallback(() => {
setResults([]);
setSelectedResult(null);
if (editorRef.current) {
editorRef.current.setValue('');
}
}, []);
const formatResponse = useCallback((response) => {
if (!response) return '';
if (response.headers) {
const sortedHeaders = Object.entries(response.headers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
return sortedHeaders;
}
try {
return JSON.stringify(JSON.parse(response.body || '{}'), null, 2);
} catch {
return response.body || '';
}
}, []);
const handleResultSelect = useCallback((result) => {
setSelectedResult(result);
if (editorRef.current) {
const content = result.type === 'TCP' ?
`[TCP Response]\n${result.response.body}` :
`[HTTP Response]\nStatus: ${result.response.status}\n\n[Headers]\n${formatResponse(result.response.headers)}\n\n[Body]\n${result.response.body}`;
editorRef.current.setValue(content);
}
}, [formatResponse]);
return (
<div className="flex h-full gap-4">
{/* Results List */}
<Card className="w-64">
<CardHeader>Test Results</CardHeader>
<CardContent>
<ScrollArea className="h-[600px]">
<div className="space-y-2">
{results.map((result, index) => (
<div
key={index}
onClick={() => handleResultSelect(result)}
className={cn(
"p-2 rounded cursor-pointer hover:bg-accent",
selectedResult === result && "bg-accent"
)}
>
<div className="font-medium truncate">{result.request.name}</div>
<div className="text-sm text-muted-foreground">
Status: {result.response.status || 'N/A'}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Result Details */}
<Card className="flex-1">
<CardHeader className="flex flex-row items-center justify-between">
<div>Result Details</div>
<Button variant="outline" onClick={reset}>
Clear
</Button>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
<TabsContent value="body" className="h-[600px]">
<div id="test-output" className="h-full w-full border rounded-md"></div>
</TabsContent>
<TabsContent value="console" className="h-[600px]">
<div id="test-console" className="h-full w-full border rounded-md bg-zinc-950 text-zinc-50 p-4 font-mono overflow-auto">
{results.map((result, index) => (
<div key={index} className="whitespace-pre-wrap mb-2">
{`[${new Date(result.timestamp).toISOString()}] ${result.request.name}\n`}
{`Status: ${result.response.status}\n`}
{`Time: ${result.response.time}ms\n`}
{`Size: ${result.response.size} bytes\n`}
{'='.repeat(50)}
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
};
export default LoadTestResult;
+176
View File
@@ -0,0 +1,176 @@
import React, { useState, useCallback } from 'react';
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
const LoadTester = ({
contextPath = '/',
scenarios = [],
test,
onTestUpdate
}) => {
const [isLoading, setIsLoading] = useState(false);
// Handler for starting the load test
const handleStartTest = useCallback(async () => {
if (!test?.scenarioId) {
alert('Please select a scenario');
return;
}
setIsLoading(true);
try {
const response = await fetch(`${contextPath}startLoadTest`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
},
body: JSON.stringify(test)
});
if (!response.ok) {
throw new Error('Failed to start test');
}
} catch (error) {
console.error('Error starting test:', error);
alert(error.message);
} finally {
setIsLoading(false);
}
}, [test, contextPath]);
// Handler for stopping the load test
const handleStopTest = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`${contextPath}stopLoadTest`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
},
body: JSON.stringify(test)
});
if (!response.ok) {
throw new Error('Failed to stop test');
}
} catch (error) {
console.error('Error stopping test:', error);
alert(error.message);
} finally {
setIsLoading(false);
}
}, [test, contextPath]);
// Handler for input changes
const handleChange = useCallback((field, value) => {
onTestUpdate({
...test,
[field]: value
});
}, [test, onTestUpdate]);
if (!test) return null;
return (
<Card>
<CardHeader>Load Test Configuration</CardHeader>
<CardContent>
<div className="grid gap-4">
{/* Scenario Selection */}
<div className="space-y-2">
<Label>Scenario</Label>
<Select
value={test.scenarioId || ''}
onValueChange={(value) => handleChange('scenarioId', value)}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select scenario" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Select scenario</SelectItem>
{scenarios.map((scenario) => (
<SelectItem key={scenario.id} value={scenario.id}>
{scenario.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Thread Count */}
<div className="space-y-2">
<Label>Thread Count</Label>
<Input
type="number"
min="1"
value={test.threadCount || 1}
onChange={(e) => handleChange('threadCount', parseInt(e.target.value))}
disabled={isLoading}
/>
</div>
{/* Loop Count */}
<div className="space-y-2">
<Label>Loop Count</Label>
<Input
type="number"
min="0"
value={test.loopCount || 0}
onChange={(e) => handleChange('loopCount', parseInt(e.target.value))}
disabled={isLoading}
/>
</div>
{/* Ramp Up Time */}
<div className="space-y-2">
<Label>Ramp-up Time (ms)</Label>
<Input
type="number"
min="0"
value={test.rampUpTime || 0}
onChange={(e) => handleChange('rampUpTime', parseInt(e.target.value))}
disabled={isLoading}
/>
</div>
{/* Duration */}
<div className="space-y-2">
<Label>Duration (minutes)</Label>
<Input
type="number"
min="0"
value={test.duration || 0}
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
disabled={isLoading}
/>
</div>
{/* Control Buttons */}
<div className="flex gap-2 pt-4">
<Button
onClick={handleStartTest}
disabled={isLoading}
>
Start Test
</Button>
<Button
onClick={handleStopTest}
disabled={isLoading}
variant="secondary"
>
Stop Test
</Button>
</div>
</div>
</CardContent>
</Card>
);
};
export default LoadTester;
+133
View File
@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Label } from '@/components/ui/label';
const LoginDialog = ({ open, onOpenChange }) => {
const [formData, setFormData] = useState({ id: '', password: '' });
const [message, setMessage] = useState({ type: '', content: '' });
const [isLoading, setIsLoading] = useState(false);
const getCsrfToken = () => {
const name = 'XSRF-TOKEN=';
const decodedCookie = decodeURIComponent(document.cookie);
const cookieArray = decodedCookie.split(';');
for (let cookie of cookieArray) {
cookie = cookie.trim();
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length, cookie.length);
}
}
return null;
};
const showMessage = (type, content) => {
setMessage({ type, content });
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage({ type: '', content: '' });
try {
const response = await fetch('/actionLogin.do', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken()
},
body: new URLSearchParams({
id: formData.id,
password: formData.password
})
});
const data = await response.json();
switch(data.status) {
case 'SUCCESS':
window.location.href = data.redirectUrl;
break;
case 'DORMANT':
case 'PASSWORD_EXPIRED':
showMessage('warning', data.message);
if (data.redirectUrl) {
setTimeout(() => {
window.location.href = data.redirectUrl;
}, 2000);
}
break;
default:
showMessage('error', '로그인 처리 중 오류가 발생했습니다.');
}
} catch (error) {
showMessage('error', '로그인 처리 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>로그인</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
{message.content && (
<Alert variant={message.type === 'error' ? 'destructive' : 'default'}>
<AlertDescription>{message.content}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="id" className="text-right">
아이디
</Label>
<Input
id="id"
value={formData.id}
onChange={(e) => setFormData(prev => ({ ...prev, id: e.target.value }))}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="password" className="text-right">
비밀번호
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="col-span-3"
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? '로그인 중...' : '로그인'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default LoginDialog;
@@ -0,0 +1,169 @@
import React, {useEffect, useState} from 'react';
import APINavigation from "@/components/api-tester/APINavigation";
import APITabs from "@/components/api-tester/APITabs";
import { useAPI } from '@/providers/APIProvider.jsx';
import ScenarioNavigation from '@/components/api-tester/ScenarioNavigation.jsx';
import ScenarioEditor from '@/components/api-tester/ScenarioEditor.jsx';
const MainContentArea = ({ isApiMode, toggleMode }) => {
return (
<div className="h-full w-full">
{isApiMode ? <APIMode isApiMode={isApiMode} onToggleMode={toggleMode}/> : <ScenarioMode isApiMode={isApiMode} onToggleMode={toggleMode}/>}
</div>
);
};
// API 모드
const APIMode = ({ isApiMode, onToggleMode }) => {
const [apiRequests, setApiRequests] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const {
collections,
loadCollections,
loading,
error
} = useAPI();
useEffect(() => {
loadCollections();
}, [loadCollections]);
const handleTabChange = (value) => {
setCurrentIndex(Number(value));
};
const handleCloseTab = (index) => {
const updatedRequests = [...apiRequests];
updatedRequests.splice(index, 1);
setApiRequests(updatedRequests);
if (currentIndex >= updatedRequests.length) {
setCurrentIndex(Math.max(0, updatedRequests.length - 1));
}
};
const handleOpenApi = (collectionId, apiId) => {
const collection = collections.find((col) => col.id === collectionId);
if (!collection) {
console.error(`Collection with id "${collectionId}" not found`);
return;
}
const api = collection.apis?.find((api) => api.id === apiId);
if (!api) {
console.error(`API with id "${apiId}" not found in collection "${collectionId}"`);
return;
}
const newApiRequest = {
id: api.id,
name: api.name,
type: api.type || 'HTTP',
method: api.method || 'GET',
path: api.path || '',
headers: api.headers || [],
queryParams: api.queryParams || [],
contentType: api.contentType || 'application/json',
requestBody: api.requestBody || '',
preRequestScript: api.preRequestScript || '',
postRequestScript: api.postRequestScript || '',
collectionId: collectionId,
collectionName: collection.name,
server: api.server || null,
description: api.description || ''
};
const existingIndex = apiRequests.findIndex(
(request) => request.id === apiId && request.collectionId === collectionId
);
if (existingIndex === -1) {
setApiRequests((prevRequests) => [...prevRequests, newApiRequest]);
setCurrentIndex(apiRequests.length);
} else {
const updatedRequests = [...apiRequests];
updatedRequests[existingIndex] = newApiRequest;
setApiRequests(updatedRequests);
setCurrentIndex(existingIndex);
}
};
const renderFallback = () => (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<p>No APIs opened. Select an API to test from the sidebar.</p>
</div>
);
return (
<div className="api-mode flex h-full w-full">
<div className="navigation w-[250px] flex-shrink-0 border-r bg-background">
{/* APINavigation 고정 */}
<APINavigation
contextPath="/"
collections={collections} // Pass collections to APINavigation
side="left"
onApiSelect={handleOpenApi}
onNewCollection={() => {
// Handle new collection creation
}}
isApiMode={isApiMode}
onToggleMode={onToggleMode}
/>
</div>
<div className="flex-1 h-full w-full min-w-0 overflow-hidden">
{apiRequests.length === 0 ? (
renderFallback() // Render fallback when no tabs exist
) : (
<APITabs
apiRequests={apiRequests}
currentIndex={currentIndex}
onTabChange={handleTabChange}
onCloseTab={handleCloseTab}
/>
)}
</div>
</div>
);
};
// 시나리오 모드
const ScenarioMode = ({ isApiMode, onToggleMode }) => (
<div className="scenario-mode flex flex-1">
{/* 왼쪽 고정: ScenarioNavigation */}
<div className="navigation-sidebar min-w-[200px] border-r bg-gray-100">
<ScenarioNavigation
contextPath="/"
side="left"
onScenarioSelect={(scenarioId) => {
// Handle scenario selection
}}
isApiMode={isApiMode}
onToggleMode={onToggleMode}
/>
</div>
{/* 중앙: ScenarioEditor */}
<div className="editor flex-1">
<ScenarioEditor/>
</div>
{/* 오른쪽 고정: APINavigation */}
<div className="navigation-sidebar min-w-[200px] border-l bg-gray-100">
<APINavigation contextPath="/"
side="right"
onApiSelect={(collectionId, apiId) => {
// Handle API selection
}}
onNewCollection={() => {
// Handle new collection creation
}}
isApiMode={isApiMode}
onToggleMode={onToggleMode}
/>
</div>
</div>
);
export default MainContentArea;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2 } from 'lucide-react';
import { useAPI } from '@/providers/APIProvider';
const ServerManager = ({ onServerChange = () => {} }) => {
const { servers, loading, error, loadServers } = useAPI();
const initialized = useRef(false);
const [selectedServer, setSelectedServer] = useState(null);
// Initial load of servers
useEffect(() => {
const initializeServers = async () => {
if (!initialized.current) {
initialized.current = true;
await loadServers();
console.log(servers);
}
};
initializeServers();
}, [loadServers]);
// Handle server selection when servers are loaded
useEffect(() => {
if (servers?.length > 0 && !loading && !selectedServer) {
const defaultServer = servers[0];
setSelectedServer(defaultServer);
onServerChange(defaultServer.id);
}
}, [servers, loading, onServerChange, selectedServer]);
const handleServerChange = (serverId) => {
const server = servers.find(s => s.id === serverId);
if (server) {
setSelectedServer(server);
onServerChange(serverId);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>서버 목록 불러오는 ...</span>
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="w-full max-w-md">
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (!servers?.length) {
return (
<Alert className="w-full max-w-md">
<AlertDescription>사용 가능한 서버가 없습니다.</AlertDescription>
</Alert>
);
}
return (
<Select
value={selectedServer?.id}
onValueChange={handleServerChange}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="서버 선택">
{selectedServer?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{servers.map((server) => (
<SelectItem
key={server.id}
value={server.id}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default ServerManager;
@@ -0,0 +1,307 @@
import React, {useEffect, useState} from 'react';
import {ChevronRight} from 'lucide-react';
import {Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider} from '@/components/ui/sidebar';
import {ScrollArea} from '@/components/ui/scroll-area';
import {Alert, AlertDescription} from '@/components/ui/alert';
import {Button} from '@/components/ui/button';
import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip';
import {useAPI} from '@/providers/APIProvider';
import DraggableApiItem from '@/components/api-tester/DraggableApiItem';
import DeleteCollectionDialog from './DeleteCollectionDialog';
import RenameCollectionDialog from './RenameCollectionDialog';
import CollectionDropdownMenu from './CollectionDropdownMenu';
import DeleteAPIDialog from '@/components/api-tester/DeleteApiDialog.jsx';
import RenameAPIDialog from './RenameAPIDialog';
import AddAPIDialog from '@/components/api-tester/AddAPIDialog';
const APINavigation = ({
contextPath = '/',
onApiSelect,
side,
isApiMode,
onToggleMode
}) => {
// State
const [selectedCollection, setSelectedCollection] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [collectionToDelete, setCollectionToDelete] = useState(null);
const [collectionToRename, setCollectionToRename] = useState(null);
const [showAddApi, setShowAddApi] = useState(false);
const [apiToDelete, setApiToDelete] = useState(null);
const [apiToRename, setApiToRename] = useState(null);
const [renameApiDialogOpen, setRenameApiDialogOpen] = useState(false);
// API Context
const {
collections,
loading,
error,
loadCollections,
deleteCollection,
updateCollection,
deleteAPIRequest,
createAPIRequest,
updateAPIRequest
} = useAPI();
// Load collections on mount
useEffect(() => {
loadCollections();
}, [contextPath]);
// Handlers
const handleCollectionClick = (collectionId, event) => {
// Prevent click when clicking dropdown menu
if (!event.target.closest('[data-dropdown-trigger="true"]')) {
setSelectedCollection(selectedCollection === collectionId ? null : collectionId);
}
};
const handleApiAction = {
open: (collectionId, apiId) => {
if (onApiSelect) onApiSelect(collectionId, apiId);
},
delete: async (collectionId, apiId) => {
setApiToDelete({ collectionId, apiId });
},
rename: (collectionId, apiId) => {
const collection = collections.find(c => c.id === collectionId);
const api = collection?.apis?.find(a => a.id === apiId);
if (api) {
setApiToRename({ ...api, collectionId });
setRenameApiDialogOpen(true);
}
}
};
const handleRenameApi = async (apiId, newName) => {
if (apiToRename) {
try {
const updatedApi = {
...apiToRename,
name: newName
};
await updateAPIRequest(updatedApi);
setApiToRename(null);
setRenameApiDialogOpen(false);
await loadCollections(); // Refresh the collections
return true;
} catch (error) {
console.error('Failed to rename API:', error);
return false;
}
}
};
const handleDeleteApi = async () => {
if (apiToDelete) {
try {
await deleteAPIRequest(apiToDelete.collectionId, apiToDelete.apiId);
setApiToDelete(null);
return true;
} catch (error) {
console.error('Failed to delete API:', error);
return false;
}
}
};
const handleAddApi = async (collectionId, apiData) => {
try {
await createAPIRequest(collectionId, apiData);
return true;
} catch (error) {
console.error('Failed to create API:', error);
return false;
}
};
const handleCollectionAction = {
delete: async (collection) => {
setCollectionToDelete(collection);
setDeleteDialogOpen(true);
},
rename: (collection) => {
setCollectionToRename(collection);
setRenameDialogOpen(true);
},
addRequest: (collection) => {
setSelectedCollection(collection);
setShowAddApi(true);
}
};
const handleDeleteConfirm = async () => {
if (collectionToDelete) {
try {
const success = await deleteCollection(collectionToDelete.id);
if (success) {
// If the deleted collection was selected, clear the selection
if (selectedCollection === collectionToDelete.id) {
setSelectedCollection(null);
}
}
} catch (error) {
console.error('Failed to delete collection:', error);
}
}
};
const handleRenameConfirm = async (collectionId, newName) => {
try {
const success = await updateCollection(collectionId, newName);
if (success) {
await loadCollections();
return true;
}
return false;
} catch (error) {
console.error('Failed to rename collection:', error);
throw error;
}
};
// Render helpers
const renderApiList = (collection) => {
if (selectedCollection !== collection.id || !collection.apis?.length) {
return null;
}
return collection.apis.map((api) => (
<DraggableApiItem
key={api.id}
api={api}
collectionId={collection.id}
onApiAction={handleApiAction}
/>
));
};
const renderCollectionItem = (collection) => (
<SidebarMenuItem key={collection.id}>
<SidebarMenuButton
onClick={(e) => handleCollectionClick(collection.id, e)}
className="w-full justify-start gap-2"
asChild
>
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
<ChevronRight
className={`flex-shrink-0 transition-transform ${
selectedCollection === collection.id ? 'rotate-90' : ''
}`}
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate flex-1">{collection.name}</span>
</TooltipTrigger>
<TooltipContent>
<p>{collection.name}</p>
</TooltipContent>
</Tooltip>
<SidebarMenuAction className="p-0 m-0 flex-shrink-0">
<CollectionDropdownMenu
collection={collection}
onAddRequest={() => handleCollectionAction.addRequest(collection)}
onRename={() => handleCollectionAction.rename(collection)}
onDelete={() => handleCollectionAction.delete(collection)}
/>
</SidebarMenuAction>
</div>
</SidebarMenuButton>
{renderApiList(collection)}
</SidebarMenuItem>
);
const renderContent = () => {
if (loading) {
return (
<SidebarContent>
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
</SidebarContent>
);
}
if (error) {
return (
<SidebarContent>
<Alert className="m-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
</SidebarContent>
);
}
return (
<SidebarContent>
<ScrollArea className="h-[calc(100vh-64px)]">
<SidebarGroup>
<SidebarGroupLabel>Collections</SidebarGroupLabel>
<SidebarMenu>
{collections.map(renderCollectionItem)}
</SidebarMenu>
</SidebarGroup>
</ScrollArea>
</SidebarContent>
);
};
return (
<SidebarProvider>
<Sidebar side={side} className="py-16">
{side === 'left' && (
<SidebarHeader>
<Button
variant="outline"
className="w-full"
onClick={onToggleMode}
>
{isApiMode ? 'Switch to Scenario Mode' : 'Switch to API Mode'}
</Button>
</SidebarHeader>
)}
{renderContent()}
</Sidebar>
<DeleteCollectionDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
collectionName={collectionToDelete?.name || ''}
/>
<RenameCollectionDialog
open={renameDialogOpen}
onOpenChange={setRenameDialogOpen}
onSubmit={handleRenameConfirm}
collection={collectionToRename}
/>
<DeleteAPIDialog
open={!!apiToDelete}
onOpenChange={(open) => !open && setApiToDelete(null)}
onConfirm={handleDeleteApi}
apiName={apiToDelete?.name || ''}
/>
<AddAPIDialog
open={showAddApi}
onOpenChange={setShowAddApi}
onSubmit={handleAddApi}
selectedCollection={collections.find(c => c.id === selectedCollection)}
/>
<RenameAPIDialog
open={renameApiDialogOpen}
onOpenChange={setRenameApiDialogOpen}
onSubmit={handleRenameApi}
api={apiToRename}
/>
</SidebarProvider>
);
};
export default APINavigation;
@@ -0,0 +1,79 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HTTPClientView } from '../client-views/HTTPClientView';
import { TCPClientView } from '../client-views/TCPClientView';
import { HTTPFlatClientView } from '../client-views/HTTPFlatClientView';
import { X } from 'lucide-react';
import {Button} from '@/components/ui/button.jsx';
const APITabs = ({
apiRequests,
currentIndex,
onTabChange,
onCloseTab
}) => {
const renderTabContent = (api, index) => {
const commonProps = {
model: api,
index
};
switch (api.type) {
case 'HTTP':
return <HTTPClientView {...commonProps} />;
case 'TCP':
return <TCPClientView {...commonProps} />;
case 'HTTPFlat':
return <HTTPFlatClientView {...commonProps} />;
default:
return null;
}
};
return (
<Tabs
value={currentIndex.toString()}
onValueChange={onTabChange}
className="flex flex-col h-full w-full"
>
<div className="border-b bg-background w-full">
<TabsList className="h-auto justify-start w-full">
{apiRequests.map((request, index) => (
<div key={`${request.id}-${index}`} className="flex items-center">
<TabsTrigger
value={String(index)}
className="flex items-center gap-2 data-[state=active]:bg-background"
>
<span className="truncate max-w-[150px]">{request.name}</span>
<Button
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onCloseTab(index);
}}
>
<X className="h-4 w-4"/>
</Button>
</TabsTrigger>
</div>
))}
</TabsList>
</div>
<div className="flex-1 w-full overflow-hidden">
{apiRequests.map((request, index) => (
<TabsContent
key={request.id}
value={index.toString()}
className="flex-1 h-full m-0 border-none p-0 outline-none data-[state=active]:flex-1"
>
{renderTabContent(request, index)}
</TabsContent>
))}
</div>
</Tabs>
);
};
export default APITabs;
@@ -0,0 +1,185 @@
import React, {useEffect, useState} from 'react';
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog';
import {useAPI} from '@/providers/APIProvider';
const AddAPIDialog = ({open, onOpenChange, onSubmit, selectedCollection}) => {
const {collections, loadCollections} = useAPI();
const [formData, setFormData] = useState({
name: '',
type: '',
collectionId: '',
});
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
if (selectedCollection?.id) {
setFormData(prev => ({
...prev,
collectionId: selectedCollection.id
}));
}
}, [selectedCollection]);
useEffect(() => {
if (open && !isInitialized) {
loadCollections();
setIsInitialized(true);
}
}, [open, isInitialized, loadCollections]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
setError('API 이름을 입력해주세요');
return;
}
if (!formData.type) {
setError('API 유형을 선택해주세요');
return;
}
if (!formData.collectionId) {
setError('컬렉션을 선택해주세요');
return;
}
setIsSubmitting(true);
try {
const success = await onSubmit(formData.collectionId, {
name: formData.name.trim(),
type: formData.type
});
if (success) {
setFormData({name: '', type: '', collectionId: ''});
setError('');
onOpenChange(false);
} else {
setError('API 생성에 실패했습니다');
setShowErrorDialog(true);
}
} catch (err) {
setError(err.message || 'API 생성에 실패했습니다');
setShowErrorDialog(true);
} finally {
setIsSubmitting(false);
}
};
const handleOpenChange = (open) => {
if (!open) {
setFormData({
name: '',
type: '',
collectionId: selectedCollection?.id || '' // selectedCollection이 있다면 유지
});
setError('');
setIsInitialized(false); // Dialog가 닫힐 때 초기화 상태를 리셋
}
onOpenChange(open);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle> API 추가</DialogTitle>
<DialogDescription>
새로운 API의 정보를 입력해주세요
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="collection">컬렉션</Label>
<Select
value={formData.collectionId}
onValueChange={(value) =>
setFormData(prev => ({...prev, collectionId: value}))
}
>
<SelectTrigger>
<SelectValue placeholder="컬렉션을 선택하세요"/>
</SelectTrigger>
<SelectContent>
{collections.map((collection) => (
<SelectItem key={collection.id} value={collection.id}>
{collection.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="type">API 유형</Label>
<Select
value={formData.type}
onValueChange={(value) =>
setFormData(prev => ({...prev, type: value}))
}
>
<SelectTrigger>
<SelectValue placeholder="API 유형을 선택하세요"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="HTTPFlat">HTTP Flat</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="name">API 이름</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => {
setFormData(prev => ({...prev, name: e.target.value}));
setError('');
}}
placeholder="API 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
취소
</Button>
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? '생성 중...' : '생성'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ErrorAlertDialog
open={showErrorDialog}
onOpenChange={setShowErrorDialog}
title="API 생성 오류"
description={error}
/>
</>
);
};
export default AddAPIDialog;
@@ -0,0 +1,113 @@
// /components/AddCollectionDialog.jsx
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
const AddCollectionDialog = ({ open, onOpenChange, onSubmit }) => {
const [collectionName, setCollectionName] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showErrorDialog, setShowErrorDialog] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!collectionName.trim()) {
setError('컬렉션 이름을 입력해주세요');
return;
}
setIsSubmitting(true);
try {
const success = await onSubmit(collectionName.trim());
if (success) {
setCollectionName('');
setError('');
onOpenChange(false);
} else {
setError('컬렉션 생성에 실패했습니다');
setShowErrorDialog(true);
}
} catch (err) {
setError(err.message || '컬렉션 생성에 실패했습니다');
setShowErrorDialog(true);
} finally {
setIsSubmitting(false);
}
};
// Reset form when dialog is closed
const handleOpenChange = (open) => {
if (!open) {
setCollectionName('');
setError('');
}
onOpenChange(open);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle> 컬렉션 추가</DialogTitle>
<DialogDescription>
새로운 컬렉션의 이름을 입력해주세요
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">컬렉션 이름</Label>
<Input
id="name"
value={collectionName}
onChange={(e) => {
setCollectionName(e.target.value);
setError('');
}}
placeholder="컬렉션 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
취소
</Button>
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? '생성 중...' : '생성'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ErrorAlertDialog
open={showErrorDialog}
onOpenChange={setShowErrorDialog}
title="컬렉션 생성 오류"
description={error}
/>
</>
);
};
export default AddCollectionDialog;
@@ -0,0 +1,29 @@
import React from 'react';
import {MoreHorizontal} from 'lucide-react';
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu';
const CollectionDropdownMenu = ({ collection, onAddRequest, onRename, onDelete }) => (
<DropdownMenu>
<DropdownMenuTrigger data-dropdown-trigger="true" asChild>
<div className="p-0 h-auto hover:bg-accent rounded-sm">
<MoreHorizontal className="h-4 w-4"/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onAddRequest}>
요청 추가
</DropdownMenuItem>
<DropdownMenuItem onClick={onRename}>
이름 변경
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(collection)}
>
삭제
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
export default CollectionDropdownMenu;
@@ -0,0 +1,44 @@
import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const DeleteAPIDialog = ({ open, onOpenChange, onConfirm, apiName }) => {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>API 삭제</AlertDialogTitle>
<AlertDialogDescription>
'{apiName}' API를 삭제하시겠습니까?
<br />
작업은 되돌릴 없습니다.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className="bg-destructive hover:bg-destructive/90"
>
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteAPIDialog;
@@ -0,0 +1,44 @@
import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const DeleteCollectionDialog = ({ open, onOpenChange, onConfirm, collectionName }) => {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>컬렉션 삭제</AlertDialogTitle>
<AlertDialogDescription>
'{collectionName}' 컬렉션을 삭제하시겠습니까?
<br />
작업은 되돌릴 없으며, 컬렉션에 포함된 모든 API 요청도 함께 삭제됩니다.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className="bg-destructive hover:bg-destructive/90"
>
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteCollectionDialog;
@@ -0,0 +1,285 @@
import { Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider } from '@xyflow/react';
import React, { useCallback, memo, useMemo, useRef, useState, useEffect } from 'react';
import { Play } from 'lucide-react';
// Optimized node components with proper prop types and memoization
const StartNode = memo(({ data, isConnectable }) => {
// Memoize handles configuration
const handles = useMemo(() => ([
{ id: 'start-right', type: 'source', position: Position.Right }
]), []);
return (
<div className="bg-green-50 rounded-lg border-2 border-green-500 shadow-lg p-4 w-[100px]">
{handles.map(handle => (
<Handle
key={handle.id}
type={handle.type}
position={handle.position}
isConnectable={isConnectable}
className="w-3 h-3 bg-green-500"
/>
))}
<div className="flex items-center justify-center gap-2">
<Play className="h-4 w-4 text-green-600" />
<span className="font-semibold text-sm text-green-700">Start</span>
</div>
</div>
);
});
const ApiNode = memo(({ data, isConnectable }) => {
// Memoize handles and colors
const handles = useMemo(() => ([
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
]), [data.id]);
const methodColor = useMemo(() => {
const colors = {
GET: 'bg-green-500',
POST: 'bg-blue-500',
PUT: 'bg-yellow-500',
DELETE: 'bg-red-500',
default: 'bg-gray-500'
};
return colors[data.method] || colors.default;
}, [data.method]);
return (
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
{handles.map(handle => (
<Handle
key={handle.id}
type={handle.type}
position={handle.position}
isConnectable={isConnectable}
className="w-3 h-3 bg-gray-500"
/>
))}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${methodColor}`} />
<span className="font-bold text-sm">{data.method}</span>
</div>
</div>
<div className="text-xs text-gray-500">{data.name}</div>
<div className="text-sm truncate text-gray-600 mb-2">
{data.path}
</div>
</div>
);
});
const ConditionNode = memo(({ data, isConnectable }) => {
// Memoize handle styles
const handleStyles = useMemo(() => ({
true: {
top: '30%',
backgroundColor: '#22c55e',
border: '2px solid #16a34a'
},
false: {
top: '70%',
backgroundColor: '#ef4444',
border: '2px solid #dc2626'
}
}), []);
return (
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
<Handle
type="target"
position={Position.Left}
id="in"
isConnectable={isConnectable}
className="w-3 h-3"
/>
<Handle
type="source"
position={Position.Right}
id="true"
style={handleStyles.true}
isConnectable={isConnectable}
className="w-3 h-3 !opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="false"
style={handleStyles.false}
isConnectable={isConnectable}
className="w-3 h-3 !opacity-100"
/>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-500" />
<span className="font-bold text-sm">Condition</span>
</div>
</div>
<div className="text-sm text-gray-600">
{data.condition || 'Add condition'}
</div>
</div>
);
});
const DelayNode = memo(({ data, isConnectable }) => {
const handles = useMemo(() => ([
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
]), [data.id]);
return (
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
{handles.map(handle => (
<Handle
key={handle.id}
type={handle.type}
position={handle.position}
isConnectable={isConnectable}
className="w-3 h-3 bg-gray-500"
/>
))}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-orange-500" />
<span className="font-bold text-sm">Delay</span>
</div>
</div>
<div className="text-sm text-gray-600">
Delay: {data.duration || '1000'}ms
</div>
</div>
);
});
// Add display names for debugging
StartNode.displayName = 'StartNode';
ApiNode.displayName = 'ApiNode';
ConditionNode.displayName = 'ConditionNode';
DelayNode.displayName = 'DelayNode';
// Memoize initial nodes and edges
const initialNodes = [
{
id: 'start',
type: 'startNode',
position: { x: 100, y: 50 },
data: { id: 'start' }
}
];
const initialEdges = [];
// Memoize node types
const nodeTypes = {
startNode: StartNode,
apiNode: ApiNode,
conditionNode: ConditionNode,
delayNode: DelayNode
};
const DiagramArea = () => {
const reactFlowWrapper = useRef(null);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [selectedNodeId, setSelectedNodeId] = useState(null);
// Click handlers for node selection
const onNodeClick = useCallback((event, node) => {
event.stopPropagation();
setSelectedNodeId(node.id);
}, []);
const onPaneClick = useCallback(() => {
setSelectedNodeId(null);
}, []);
// Apply styles to nodes
useEffect(() => {
setNodes((nds) =>
nds.map((node) => ({
...node,
className: node.id === selectedNodeId ? 'border-2 border-primary shadow-md bg-primary/5' : null
}))
);
}, [selectedNodeId, setNodes]);
// Memoize callback functions
const onConnect = useCallback((params) => {
setEdges(prev => [...prev, { ...params, id: `e${prev.length + 1}` }]);
}, []);
const onInit = useCallback((instance) => {
reactFlowWrapper.current = instance;
}, []);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}, []);
const onDrop = useCallback((event) => {
event.preventDefault();
if (!reactFlowWrapper.current) return;
try {
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
const position = reactFlowWrapper.current.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode = {
id: `api-${Date.now()}`,
type: 'apiNode',
position,
data: {
id: apiData.id,
name: apiData.name,
method: apiData.method,
path: apiData.path,
collectionId: apiData.collectionId
}
};
setNodes(prev => prev.concat(newNode));
} catch (error) {
console.error('Error adding new node:', error);
}
}, [setNodes]);
// Default edge options memoized
const defaultEdgeOptions = useMemo(() => ({ type: 'smoothstep' }), []);
return (
<div className="h-[50vh] border-b">
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={onInit}
defaultEdgeOptions={defaultEdgeOptions}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
defaultZoom={1.0} // 초기 배율 설정 (1이 기본값)
proOptions={{ hideAttribution: true }}
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</ReactFlowProvider>
</div>
);
};
export default memo(DiagramArea);
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { FileText, MoreHorizontal } from 'lucide-react';
import { SidebarMenuSubItem, SidebarMenuSubButton, SidebarMenuAction } from '@/components/ui/sidebar';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
const ApiDropdownMenu = ({ onOpen, onRename, onDelete }) => (
<DropdownMenu>
<DropdownMenuTrigger data-dropdown-trigger="true" className="p-0 h-auto">
<div className="hover:bg-accent rounded-sm">
<MoreHorizontal className="h-4 w-4"/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen}>
Open
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={onRename}>
Rename
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const DraggableApiItem = ({ api, collectionId, onApiAction }) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (event) => {
// Prepare API data for drag operation
const apiData = {
id: api.id,
name: api.name,
method: api.method || 'GET',
path: api.path || '/',
collectionId: collectionId,
headers: api.headers || [],
parameters: api.parameters || [],
body: api.body || '',
description: api.description || '',
type: api.type || 'HTTP'
};
// Create drag image for better visual feedback
const dragImage = document.createElement('div');
dragImage.className = 'bg-white border rounded-md shadow-lg p-2 text-sm';
dragImage.textContent = `${apiData.method} ${apiData.name}`;
document.body.appendChild(dragImage);
// Set drag image and offset
event.dataTransfer.setDragImage(dragImage, 0, 0);
// Clean up the drag image after it's no longer needed
requestAnimationFrame(() => {
document.body.removeChild(dragImage);
});
// Set the drag data
event.dataTransfer.setData('application/json', JSON.stringify(apiData));
event.dataTransfer.effectAllowed = 'copy';
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
return (
<SidebarMenuSubItem
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className={`cursor-move transition-colors duration-200 ${
isDragging ? 'opacity-50 bg-accent' : ''
}`}
>
<SidebarMenuSubButton
onClick={(e) => {
// Prevent click when dragging ends
if (!isDragging) {
onApiAction.open(collectionId, api.id);
}
}}
className="w-full justify-start gap-2"
asChild
>
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
<FileText className="h-4 w-4 flex-shrink-0"/>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate flex-1">
{api.method && <span className="font-medium text-primary">{api.method} </span>}
{api.name}
</span>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-1">
<p className="font-medium">{api.name}</p>
{api.path && <p className="text-xs text-muted-foreground">{api.path}</p>}
</div>
</TooltipContent>
</Tooltip>
<SidebarMenuAction
className="p-0 m-0 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<ApiDropdownMenu
onOpen={() => onApiAction.open(collectionId, api.id)}
onRename={() => onApiAction.rename(collectionId, api.id)}
onDelete={() => onApiAction.delete(collectionId, api.id)}
/>
</SidebarMenuAction>
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
};
export default DraggableApiItem;
@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
const RenameAPIDialog = ({ open, onOpenChange, onSubmit, api }) => {
const [apiName, setApiName] = useState(api?.name || '');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showErrorDialog, setShowErrorDialog] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!apiName.trim()) {
setError('API 이름을 입력해주세요');
return;
}
setIsSubmitting(true);
try {
const success = await onSubmit(api.id, apiName.trim());
if (success) {
setApiName('');
setError('');
onOpenChange(false);
} else {
setError('API 이름 변경에 실패했습니다');
setShowErrorDialog(true);
}
} catch (err) {
setError(err.message || 'API 이름 변경에 실패했습니다');
setShowErrorDialog(true);
} finally {
setIsSubmitting(false);
}
};
// Reset form when dialog is closed
const handleOpenChange = (open) => {
if (!open) {
setApiName(api?.name || '');
setError('');
}
onOpenChange(open);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>API 이름 변경</DialogTitle>
<DialogDescription>
API의 새로운 이름을 입력해주세요
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">API 이름</Label>
<Input
id="name"
value={apiName}
onChange={(e) => {
setApiName(e.target.value);
setError('');
}}
placeholder="API 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
취소
</Button>
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? '변경 중...' : '변경'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ErrorAlertDialog
open={showErrorDialog}
onOpenChange={setShowErrorDialog}
title="API 이름 변경 오류"
description={error}
/>
</>
);
};
export default RenameAPIDialog;
@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
const RenameCollectionDialog = ({ open, onOpenChange, onSubmit, collection }) => {
const [collectionName, setCollectionName] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showErrorDialog, setShowErrorDialog] = useState(false);
// Update collection name when collection prop changes or dialog opens
useEffect(() => {
if (collection && open) {
setCollectionName(collection.name || '');
}
}, [collection, open]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!collectionName.trim()) {
setError('컬렉션 이름을 입력해주세요');
return;
}
setIsSubmitting(true);
try {
const success = await onSubmit(collection.id, collectionName.trim());
if (success) {
setError('');
onOpenChange(false);
} else {
setError('컬렉션 이름 변경에 실패했습니다');
setShowErrorDialog(true);
}
} catch (err) {
setError(err.message || '컬렉션 이름 변경에 실패했습니다');
setShowErrorDialog(true);
} finally {
setIsSubmitting(false);
}
};
// Reset form when dialog is closed
const handleOpenChange = (open) => {
if (!open) {
setError('');
}
onOpenChange(open);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>컬렉션 이름 변경</DialogTitle>
<DialogDescription>
변경할 컬렉션의 새로운 이름을 입력해주세요
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">컬렉션 이름</Label>
<Input
id="name"
value={collectionName}
onChange={(e) => {
setCollectionName(e.target.value);
setError('');
}}
placeholder="컬렉션 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
취소
</Button>
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? '변경 중...' : '변경'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ErrorAlertDialog
open={showErrorDialog}
onOpenChange={setShowErrorDialog}
title="컬렉션 이름 변경 오류"
description={error}
/>
</>
);
};
export default RenameCollectionDialog;
@@ -0,0 +1,62 @@
import React, {useState} from 'react';
import '@xyflow/react/dist/style.css';
import MonacoEditor from '@/components/shared/MonacoEditor';
import ScenarioMenuBar from '@/components/api-tester/ScenarioMenuBar.jsx';
import DiagramArea from '@/components/api-tester/DiagramArea.jsx';
// ConsoleArea Component
const ConsoleArea = () => {
const [consoleOutput, setConsoleOutput] = useState('');
return (
<div className="h-[50vh]">
<MonacoEditor
id="scenario-console"
value={consoleOutput}
language="plaintext"
readOnly={true}
height="full"
/>
</div>
);
};
// Main ScenarioEditor Component
const ScenarioEditor = () => {
const handleSave = () => {
console.log('Saving scenario...');
};
const handleStepRun = () => {
console.log('Running step...');
};
const handleFullRun = () => {
console.log('Running all steps...');
};
const handlePause = () => {
console.log('Pausing execution...');
};
const handleReset = () => {
console.log('Resetting scenario...');
};
return (
<div className="flex flex-col h-full">
<ScenarioMenuBar
onSave={handleSave}
onStepRun={handleStepRun}
onFullRun={handleFullRun}
onPause={handlePause}
onReset={handleReset}
/>
<DiagramArea/>
<ConsoleArea/>
</div>
);
};
export default ScenarioEditor;
@@ -0,0 +1,52 @@
import {Button} from '@/components/ui/button.jsx';
import {Pause, Play, RotateCcw, Save, SkipForward} from 'lucide-react';
import React from 'react';
const ScenarioMenuBar = ({ onSave, onStepRun, onFullRun, onPause, onReset }) => {
return (
<div className="flex items-center gap-2 p-4 border-b">
<Button
variant="outline"
size="sm"
onClick={onSave}
>
<Save className="h-4 w-4 mr-2" />
저장
</Button>
<Button
variant="outline"
size="sm"
onClick={onStepRun}
>
<SkipForward className="h-4 w-4 mr-2" />
단계별 실행
</Button>
<Button
variant="outline"
size="sm"
onClick={onFullRun}
>
<Play className="h-4 w-4 mr-2" />
전체 실행
</Button>
<Button
variant="outline"
size="sm"
onClick={onPause}
>
<Pause className="h-4 w-4 mr-2" />
일시 정지
</Button>
<Button
variant="outline"
size="sm"
onClick={onReset}
>
<RotateCcw className="h-4 w-4 mr-2" />
초기화
</Button>
</div>
);
};
export default ScenarioMenuBar;
@@ -0,0 +1,169 @@
import React, { useEffect } from 'react';
import { FileText, MoreHorizontal } from 'lucide-react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction, SidebarHeader
} from '@/components/ui/sidebar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { useLoadTest } from '@/providers/LoadTestProvider';
import { SidebarProvider } from '@/components/ui/sidebar';
const ScenarioDropdownMenu = ({ onOpen, onRename, onDelete }) => (
<DropdownMenu>
<DropdownMenuTrigger data-dropdown-trigger="true" asChild>
<div className="p-0 h-auto hover:bg-accent rounded-sm">
<MoreHorizontal className="h-4 w-4"/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen}>
열기
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onRename}>
이름 변경
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
삭제
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const LoadingSpinner = () => (
<SidebarContent>
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
</SidebarContent>
);
const ErrorMessage = ({ message }) => (
<SidebarContent>
<Alert className="m-4">
<AlertDescription>{message}</AlertDescription>
</Alert>
</SidebarContent>
);
const ScenarioNavigation = ({
contextPath = '/',
onScenarioSelect,
side,
isApiMode,
onToggleMode
}) => {
const {
scenarios,
loading,
error,
loadScenarioList
} = useLoadTest();
useEffect(() => {
loadScenarioList();
}, [loadScenarioList]);
const handleScenarioAction = {
open: (scenarioId) => {
if (onScenarioSelect) {
onScenarioSelect(scenarioId);
}
},
delete: async (scenarioId) => {
// TODO: Implement delete functionality
console.log('Delete scenario:', scenarioId);
},
rename: (scenarioId) => {
// TODO: Implement rename functionality
console.log('Rename scenario:', scenarioId);
}
};
const renderScenarioItem = (scenario) => (
<SidebarMenuItem key={scenario.id}>
<SidebarMenuButton
onClick={() => handleScenarioAction.open(scenario.id)}
className="w-full justify-start gap-2"
asChild
>
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
<FileText className="h-4 w-4 flex-shrink-0"/>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate flex-1">{scenario.name}</span>
</TooltipTrigger>
<TooltipContent>
<p>{scenario.name}</p>
</TooltipContent>
</Tooltip>
<SidebarMenuAction className="p-0 m-0 flex-shrink-0">
<ScenarioDropdownMenu
onOpen={() => handleScenarioAction.open(scenario.id)}
onRename={() => handleScenarioAction.rename(scenario.id)}
onDelete={() => handleScenarioAction.delete(scenario.id)}
/>
</SidebarMenuAction>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
const renderContent = () => {
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<SidebarContent>
<ScrollArea className="h-[calc(100%-64px)]">
<SidebarGroup>
<SidebarGroupLabel>Scenarios</SidebarGroupLabel>
<SidebarMenu>
{scenarios.map(renderScenarioItem)}
</SidebarMenu>
</SidebarGroup>
</ScrollArea>
</SidebarContent>
);
};
return (
<SidebarProvider>
<Sidebar side={side} className="py-16">
<SidebarHeader>
<Button
variant="outline"
className="w-full"
onClick={onToggleMode}
>
{isApiMode ? 'Switch to Scenario Mode' : 'Switch to API Mode'}
</Button>
</SidebarHeader>
{renderContent()}
</Sidebar>
</SidebarProvider>
);
};
export default ScenarioNavigation;
@@ -0,0 +1,314 @@
import React, {useState} from 'react';
import {Button} from '@/components/ui/button';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Input} from '@/components/ui/input';
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {Label} from '@/components/ui/label';
import EditableInput from '../shared/EditableInput';
import PropertyTable from '../shared/PropertyTable';
import ServerManager from '@/components/ServerManager.jsx';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table.jsx';
import {useAPI} from '@/providers/APIProvider';
import MonacoEditor from '@/components/shared/MonacoEditor';
// import MonacoVariableEditor from './MonacoVariableEditor'; // Import the new component
const HTTP_METHODS = [
'POST', 'GET', 'PUT', 'DELETE', 'CONNECT',
'HEAD', 'OPTIONS', 'TRACE', 'PATCH'
];
export const HTTPClientView = ({model, index}) => {
const { getServer } = useAPI();
const [currentModel, setCurrentModel] = useState(model);
const [basePath, setBasePath] = useState('');
const [currentTab, setCurrentTab] = useState('body');
const [currentResponseTab, setCurrentResponseTab] = useState('response-body');
const [responseData, setResponseData] = useState({
headers: [],
status: '',
time: '',
size: ''
});
const [editorContents, setEditorContents] = useState({
requestBody: currentModel.requestBody || '',
responseBody: currentModel.responseBody || '',
console: '',
preRequestScript: currentModel.preRequestScript || '',
postRequestScript: currentModel.postRequestScript || ''
});
const handleServerChange = (serverId) => {
const selectedServer = getServer(serverId);
setCurrentModel(prev => ({
...prev,
server: serverId
}));
if (selectedServer) {
setBasePath(selectedServer.basePath || '');
}
};
const handleHeadersChange = (headers) => {
setCurrentModel(prev => ({
...prev,
headers
}));
};
const handlePathChange = (path) => {
const queryParams = parseQueryParams(path);
setCurrentModel(prev => ({
...prev,
path,
queryParams
}));
};
const parseQueryParams = (path) => {
const queryParams = [];
const queryString = path.split('?')[1];
if (queryString) {
queryString.split('&').forEach(param => {
const [key, value] = param.split('=');
queryParams.push({
enabled: true,
key,
value: value || ''
});
});
}
return queryParams;
};
const renderRequestVariables = () => (
<div className="p-4">
<h3 className="text-sm font-medium mb-2">Available Variables</h3>
<div className="space-y-2">
<div>
<Label>request</Label>
<div className="text-sm text-gray-500">Request object containing headers, body, etc.</div>
</div>
<div>
<Label>environment</Label>
<div className="text-sm text-gray-500">Current environment variables</div>
</div>
</div>
</div>
);
const renderResponseHeaders = () => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{responseData.headers.map((header, index) => (
<TableRow key={index}>
<TableCell>{header.name}</TableCell>
<TableCell>{header.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
return (
<div className="flex flex-col h-full p-4 space-y-4">
{/* Request Header Section */}
<div className="flex items-center space-x-2">
<div>
<span>{currentModel.collectionName}</span>
{' > '}
<span>{currentModel.name}</span>
</div>
</div>
{/* Request Controls */}
<div className="flex items-center space-x-2">
<Select
value={currentModel.method}
onValueChange={(value) => setCurrentModel(prev => ({...prev, method: value}))}
>
<SelectTrigger className="w-24">
<SelectValue placeholder="Method"/>
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map(method => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<ServerManager
onServerChange={handleServerChange}
/>
<Input
value={basePath} // 선택된 서버의 basePath 표시
readOnly
className="w-40"
/>
<EditableInput
value={currentModel.path}
onChange={handlePathChange}
className="flex-1"
/>
<Button>Send</Button>
<Button variant="secondary">Save</Button>
</div>
{/* Request/Response Tabs */}
<Tabs value={currentTab} onValueChange={setCurrentTab}>
<TabsList>
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="params">Params</TabsTrigger>
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="pre-request">Pre-Request</TabsTrigger>
<TabsTrigger value="post-request">Post-Request</TabsTrigger>
</TabsList>
<div className="mt-2">
{/* Body Tab Content */}
<div style={{ display: currentTab === 'body' ? 'block' : 'none' }}>
<Select
value={currentModel.contentType}
// onValueChange={(value) => setCurrentModel(prev => ({ ...prev, contentType: value }))}
onValueChange={(value) => {
// Update both content type and editor content
setCurrentModel(prev => ({
...prev,
contentType: value,
// Optionally format the content when switching to JSON
requestBody: value === 'json' && prev.requestBody ?
JSON.stringify(JSON.parse(prev.requestBody), null, 2) :
prev.requestBody
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Content Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="xml">XML</SelectItem>
<SelectItem value="plaintext">Text/Plain</SelectItem>
</SelectContent>
</Select>
<MonacoEditor
id={`request-body-${model.id}`}
value={currentModel.requestBody}
language={
currentModel.contentType
}
onChange={(newValue) => {
setCurrentModel(prev => ({
...prev,
requestBody: newValue
}));
}}
height="64"
/>
</div>
{/* Params Tab Content */}
<div style={{ display: currentTab === 'params' ? 'block' : 'none' }}>
<PropertyTable
properties={currentModel.queryParams}
onChange={handlePathChange}
/>
</div>
{/* Headers Tab Content */}
<div style={{ display: currentTab === 'headers' ? 'block' : 'none' }}>
<PropertyTable
properties={currentModel.headers}
onChange={handleHeadersChange}
/>
</div>
{/* Pre-Request Tab Content */}
<div style={{ display: currentTab === 'pre-request' ? 'block' : 'none' }} className="flex">
<div className="flex-1">
<MonacoEditor
id={`pre-request-${model.id}`}
value={editorContents.preRequestScript}
language="javascript"
onChange={(value) => {
setEditorContents(prev => ({ ...prev, preRequestScript: value }));
setCurrentModel(prev => ({ ...prev, preRequestScript: value }));
}}
/>
</div>
</div>
{/* Post-Request Tab Content */}
<div style={{ display: currentTab === 'post-request' ? 'block' : 'none' }} className="flex">
<div>
<MonacoEditor
id={`post-request-${model.id}`}
value={editorContents.postRequestScript}
language="javascript"
onChange={(value) => {
setEditorContents(prev => ({ ...prev, postRequestScript: value }));
setCurrentModel(prev => ({ ...prev, postRequestScript: value }));
}}
/>
</div>
</div>
</div>
</Tabs>
{/* Response Section */}
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<span>Response</span>
<span className="text-sm text-gray-500">{responseData.status}</span>
<span className="text-sm text-gray-500">{responseData.time}</span>
<span className="text-sm text-gray-500">{responseData.size}</span>
</div>
<Tabs value={currentResponseTab} onValueChange={setCurrentResponseTab}>
<TabsList>
<TabsTrigger value="response-body">Body</TabsTrigger>
<TabsTrigger value="response-headers">Headers</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
<div className="mt-2">
<div style={{display: currentResponseTab === 'response-body' ? 'block' : 'none'}}>
<MonacoEditor
id={`response-body-${model.id}`}
value={editorContents.responseBody}
readOnly={true}
onChange={() => {}} // Read-only, so no onChange needed
/>
</div>
<div style={{display: currentResponseTab === 'response-headers' ? 'block' : 'none'}}>
{renderResponseHeaders()}
</div>
<div style={{display: currentResponseTab === 'console' ? 'block' : 'none'}}>
<MonacoEditor
id={`console-${model.id}`}
value={editorContents.console}
language="plaintext"
readOnly={true}
/>
</div>
</div>
</Tabs>
</div>
</div>
);
};
export default HTTPClientView;
@@ -0,0 +1,288 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { useMonaco } from '@/hooks/useMonaco';
import EditableInput from '../shared/EditableInput';
import PropertyTable from '../shared/PropertyTable';
import LayoutGrid from '../layout/LayoutGrid';
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
import VariableSnippet from '../shared/VariableSnippet';
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'];
export const HTTPFlatClientView = ({ parent, serverManager, model, index }) => {
// State
const [currentTab, setCurrentTab] = useState('layout');
const [path, setPath] = useState(model.path || '');
const [queryParams, setQueryParams] = useState(model.queryParams || []);
const [headers, setHeaders] = useState(model.headers || []);
const [method, setMethod] = useState(model.method || 'GET');
const [server, setServer] = useState(model.server);
const [totalMessageLength, setTotalMessageLength] = useState(0);
// Editor refs and state
const { editor: layoutJsonEditor, containerRef: layoutJsonRef } = useMonaco({
defaultValue: JSON.stringify(model.layout, null, 2),
language: 'json'
});
const { editor: requestBodyEditor, containerRef: requestBodyRef } = useMonaco({
defaultValue: model.requestBody || '',
language: 'json'
});
const { editor: responseBodyEditor, containerRef: responseBodyRef } = useMonaco({
defaultValue: '',
language: 'json',
readOnly: true
});
const { editor: preRequestEditor, containerRef: preRequestRef } = useMonaco({
defaultValue: model.preRequestScript || '',
language: 'javascript'
});
const { editor: postRequestEditor, containerRef: postRequestRef } = useMonaco({
defaultValue: model.postRequestScript || '',
language: 'javascript'
});
const { editor: consoleEditor, containerRef: consoleRef } = useMonaco({
defaultValue: '',
language: 'text',
readOnly: true
});
// Layout tree options and state
const layoutDataTreeOptions = [
{ name: 'loutItemSerno', width: 100, isFirst: true, label: '#' },
{ name: 'loutItemName', width: 100, align: 'left', label: '항목명(영문)' },
{ name: 'loutItemDesc', width: 100, align: 'left', label: '항목설명' },
{ name: 'loutItemDepth', width: 60, align: 'center', label: '깊이' },
{ name: 'loutItemType', width: 100, align: 'center', label: '아이템 유형' },
{ name: 'loutItemOccCnt', width: 100, align: 'center', label: '반복 횟수' },
{ name: 'loutItemOccRef', width: 120, align: 'center', label: '반복 참조 필드' },
{ name: 'loutItemLength', width: 100, align: 'center', label: '데이터 길이' },
{ name: 'value', width: 200, align: 'left', label: '값', type: 'textfield' }
];
const [layoutRoot, setLayoutRoot] = useState(() => {
const root = ApiLayoutItemTreeBuilder.createTree(model.layout.layoutItems);
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
return replicator.createReplicatedTree(root);
});
// Handlers
const handlePathChange = useCallback((newPath) => {
setPath(newPath);
const newParams = parent.parseParam(newPath);
setQueryParams(newParams);
model.path = newPath;
model.queryParams = newParams;
}, [parent, model]);
const handleServerChange = useCallback((newServer) => {
setServer(newServer);
model.server = newServer;
}, [model]);
const handleMethodChange = useCallback((newMethod) => {
setMethod(newMethod);
model.method = newMethod;
}, [model]);
const handleLayoutUpdate = useCallback((updatedLayout) => {
const updatedRoot = ApiLayoutItemTreeBuilder.createTree(updatedLayout.layoutItems);
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
const newRoot = replicator.createReplicatedTree(updatedRoot);
setLayoutRoot(newRoot);
model.layout = updatedLayout;
}, [model]);
const handleLayoutDataChange = useCallback(async (finalMessage, layoutDataTree) => {
if (requestBodyEditor) {
const prefix = server && server.serverOptions
? await serverManager.renderServerPrefix(server.serverOptions, model)
: '';
requestBodyEditor.setValue(prefix + finalMessage);
}
model.layout.computedLayoutItemRoot = layoutDataTree;
}, [server, serverManager, model, requestBodyEditor]);
const handleSend = useCallback(async () => {
// Implement send logic
}, [/* dependencies */]);
const handleSave = useCallback(() => {
// Implement save logic
}, [/* dependencies */]);
// Effect for editor cleanup
useEffect(() => {
return () => {
// Cleanup logic for editors if needed
};
}, []);
return (
<div className="flex flex-col h-full gap-4 p-4">
{/* Header */}
<div className="text-sm text-muted-foreground">
{model.collectionName} &gt; {model.name}
</div>
{/* Request Controls */}
<div className="flex items-center gap-2">
<Select value={method} onValueChange={handleMethodChange}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={server} onValueChange={handleServerChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Server" />
</SelectTrigger>
<SelectContent>
{serverManager.renderHttpServers(server)}
</SelectContent>
</Select>
<Input
value={serverManager.basePath(server)}
readOnly
className="w-[180px]"
/>
<div className="flex-1">
<EditableInput
name="path"
value={path}
onChange={handlePathChange}
/>
</div>
<Button onClick={handleSend}>Send</Button>
<Button variant="secondary" onClick={handleSave}>Save</Button>
</div>
{/* Main Content */}
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1">
<TabsList>
<TabsTrigger value="layout">Layout</TabsTrigger>
<TabsTrigger value="layoutJson">Layout JSON</TabsTrigger>
<TabsTrigger value="layoutTree">Layout Tree</TabsTrigger>
<TabsTrigger value="layoutData">Layout Data</TabsTrigger>
<TabsTrigger value="param">Param</TabsTrigger>
<TabsTrigger value="header">Header</TabsTrigger>
<TabsTrigger value="preRequest">Pre-Request</TabsTrigger>
<TabsTrigger value="postRequest">Post-Request</TabsTrigger>
</TabsList>
<TabsContent value="layout" className="flex-1">
<LayoutGrid
layout={model.layout}
onChange={handleLayoutUpdate}
/>
</TabsContent>
<TabsContent value="layoutJson" className="flex-1">
<div ref={layoutJsonRef} className="h-[300px] border" />
<Button
onClick={() => {/* Implement load layout */}}
className="mt-2"
>
Load Layout
</Button>
</TabsContent>
<TabsContent value="layoutTree" className="flex-1">
<LayoutTreeGrid
root={layoutRoot}
options={layoutDataTreeOptions}
onChange={handleLayoutUpdate}
/>
</TabsContent>
<TabsContent value="layoutData" className="flex-1">
<LayoutTreeDataGrid
root={layoutRoot}
apiRequest={model}
options={layoutDataTreeOptions}
onUpdate={handleLayoutDataChange}
totalMessageLengthSelector={totalMessageLength}
/>
</TabsContent>
<TabsContent value="param" className="flex-1">
<PropertyTable
properties={queryParams}
onChange={setQueryParams}
/>
</TabsContent>
<TabsContent value="header" className="flex-1">
<PropertyTable
properties={headers}
onChange={setHeaders}
/>
</TabsContent>
<TabsContent value="preRequest" className="flex-1">
<div className="grid grid-cols-[1fr,200px] gap-4">
<div ref={preRequestRef} className="h-[300px] border" />
<VariableSnippet editor={preRequestEditor} />
</div>
</TabsContent>
<TabsContent value="postRequest" className="flex-1">
<div className="grid grid-cols-[1fr,200px] gap-4">
<div ref={postRequestRef} className="h-[300px] border" />
<VariableSnippet editor={postRequestEditor} />
</div>
</TabsContent>
</Tabs>
{/* Request Body */}
<div className="h-[150px] border">
<div ref={requestBodyRef} className="h-full" />
</div>
{/* Total Message Length */}
<div className="text-sm">
Total Message Length: {totalMessageLength}
</div>
{/* Response Section */}
<Card className="flex-1">
<Tabs defaultValue="body" className="h-full">
<TabsList>
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
<TabsContent value="body" className="flex-1">
<div ref={responseBodyRef} className="h-[250px] border" />
</TabsContent>
<TabsContent value="console" className="flex-1">
<div ref={consoleRef} className="h-[250px] border" />
</TabsContent>
</Tabs>
</Card>
</div>
);
};
// export default HTTPFlatClientView;
@@ -0,0 +1,213 @@
import React, { useEffect, useRef } from 'react';
import * as monaco from 'monaco-editor';
const registerVariableSupport = (baseLanguage = 'plaintext') => {
const languageId = `${baseLanguage}-with-variables`;
// Only register if not already registered
if (!monaco.languages.getLanguages().some(lang => lang.id === languageId)) {
// Register the new language
monaco.languages.register({ id: languageId });
// Define basic language configuration
const configuration = {
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
['{{', '}}']
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: '\'', close: '\'' },
{ open: '{{', close: '}}' }
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: '\'', close: '\'' },
{ open: '{{', close: '}}' }
]
};
// Set the configuration for our new language
monaco.languages.setLanguageConfiguration(languageId, configuration);
// Define the token provider that combines base language and variable highlighting
monaco.languages.setMonarchTokensProvider(languageId, {
defaultToken: '',
tokenizer: {
root: [
// Handle variables with highest priority
[/{{[^}]*}}/, 'variable'],
// Default token handling
[/[^{{]+/, { token: '', switchTo: '@baseLanguage' }]
],
baseLanguage: [
[/{{/, { token: 'variable', switchTo: 'root' }],
[/[^{{]+/, '']
]
}
});
// Always define the theme - Monaco will handle duplicate definitions
monaco.editor.defineTheme('variable-highlight-theme', {
base: 'vs',
inherit: true,
rules: [
{ token: 'variable', foreground: '0000ff', fontStyle: 'bold' }
],
colors: {}
});
}
return languageId;
};
// Supported language configurations
const SUPPORTED_LANGUAGES = {
json: {
formatOnType: true,
formatOnPaste: true,
defaultValue: '{}',
diagnosticsEnabled: true
},
javascript: {
formatOnType: true,
formatOnPaste: true,
defaultValue: '',
diagnosticsEnabled: true
},
html: {
formatOnType: true,
formatOnPaste: true,
defaultValue: '',
diagnosticsEnabled: true
},
xml: {
formatOnType: true,
formatOnPaste: true,
defaultValue: '<?xml version="1.0" encoding="UTF-8"?>',
diagnosticsEnabled: true
},
plaintext: {
formatOnType: false,
formatOnPaste: false,
defaultValue: '',
diagnosticsEnabled: false
}
};
const MonacoVariableEditor = ({
id,
value = '',
language = 'plaintext',
readOnly = false,
height = '64',
onChange = () => {},
options = {}
}) => {
const editorRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
// Register language with variable support
const editorLanguage = registerVariableSupport(language);
// Get language-specific configurations
const languageConfig = SUPPORTED_LANGUAGES[language] || SUPPORTED_LANGUAGES.plaintext;
// Create editor instance
editorRef.current = monaco.editor.create(containerRef.current, {
value: value || languageConfig.defaultValue,
language: editorLanguage,
automaticLayout: true,
theme: 'variable-highlight-theme',
minimap: { enabled: false },
readOnly,
fontSize: 14,
lineHeight: 21,
padding: { top: 8, bottom: 8 },
scrollBeyondLastLine: false,
formatOnType: languageConfig.formatOnType,
formatOnPaste: languageConfig.formatOnPaste,
...options,
});
// Add command to insert variable wrapper
editorRef.current.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space,
() => {
const selection = editorRef.current.getSelection();
const selectedText = editorRef.current.getModel().getValueInRange(selection);
const operation = {
range: selection,
text: selectedText ? `{{${selectedText}}}` : '{{}}'
};
editorRef.current.executeEdits('insert-variable', [operation]);
// If no text was selected, place cursor inside brackets
if (!selectedText) {
const position = new monaco.Position(
selection.startLineNumber,
selection.startColumn + 2
);
editorRef.current.setPosition(position);
}
}
);
// Set up change event handler
const changeDisposable = editorRef.current.onDidChangeModelContent(() => {
const newValue = editorRef.current.getValue();
onChange(newValue);
});
// Set up resize observer
const resizeObserver = new ResizeObserver(() => {
if (editorRef.current) {
editorRef.current.layout();
}
});
resizeObserver.observe(containerRef.current);
// Cleanup
return () => {
changeDisposable.dispose();
resizeObserver.disconnect();
if (editorRef.current) {
editorRef.current.dispose();
}
};
}, [language]); // Re-initialize when language changes
// Update value if it changes externally
useEffect(() => {
if (editorRef.current && value !== editorRef.current.getValue()) {
editorRef.current.setValue(value);
}
}, [value]);
return (
<div
id={id}
ref={containerRef}
className={`h-${height} border rounded-md overflow-hidden`}
style={{ minHeight: '100px' }}
/>
);
};
export default MonacoVariableEditor;
@@ -0,0 +1,458 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as monaco from 'monaco-editor';
import APIClient from '../../lib/api-client';
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent } from "@/components/ui/card";
import LayoutGrid from '../layout/LayoutGrid';
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
import VariableSnippet from '../shared/VariableSnippet';
export const TCPClientView = ({ parent, serverManager, model, index }) => {
const [apiClient] = useState(() => new APIClient());
const [isLoading, setIsLoading] = useState(false);
const [editors, setEditors] = useState({
layoutJson: null,
requestBody: null,
responseBody: null,
console: null,
preRequest: null,
postRequest: null
});
const [layoutData, setLayoutData] = useState(null);
// Refs
const layoutJsonRef = useRef(null);
const requestBodyRef = useRef(null);
const responseBodyRef = useRef(null);
const consoleRef = useRef(null);
const preRequestRef = useRef(null);
const postRequestRef = useRef(null);
const totalMessageLengthRef = useRef(null);
const layoutGridRef = useRef(null);
const handleResponseDisplay = useCallback((response) => {
try {
editors.responseBody?.setValue(response.body || '');
if (response.time) {
const responseTimeEl = document.querySelector('.response_time');
if (responseTimeEl) {
responseTimeEl.textContent = `time: ${response.time}ms`;
}
}
if (response.size) {
const responseSizeEl = document.querySelector('.response_size');
if (responseSizeEl) {
responseSizeEl.textContent = `size: ${response.size}bytes`;
}
}
} catch (error) {
console.error('Error displaying response:', error);
}
}, [editors.responseBody]);
const handleLoadLayout = useCallback(() => {
try {
const newLayout = JSON.parse(editors.layoutJson?.getValue() || '{}');
model.layout = newLayout;
if (newLayout.layoutItems) {
newLayout.layoutItems.forEach((item) => {
if (item.value === undefined) {
item.value = '';
}
});
}
const layoutRoot = ApiLayoutItemTreeBuilder.createTree(newLayout.layoutItems);
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
const layoutDataRoot = replicator.createReplicatedTree(layoutRoot);
setLayoutData(layoutDataRoot);
if (layoutGridRef.current) {
layoutGridRef.current.rebuild(newLayout);
}
} catch (error) {
console.error('Error loading layout:', error);
alert(error.message);
}
}, [model, editors.layoutJson]);
const handleSendRequest = useCallback(async () => {
if (!model.server) {
alert('서버를 등록해주세요.');
return;
}
setIsLoading(true);
try {
editors.responseBody?.setValue('');
editors.console?.setValue('');
const startTime = Date.now();
const response = await apiClient.sendAPIRequest(
model,
null,
(message) => {
const consoleEditor = editors.console;
if (consoleEditor) {
const model = consoleEditor.getModel();
if (model) {
const lastLine = model.getLineCount();
const range = new monaco.Range(lastLine, 1, lastLine, 1);
const text = typeof message === 'object' ? JSON.stringify(message, null, 2) : message;
consoleEditor.executeEdits('console', [{
identifier: { major: 1, minor: 1 },
range,
text: text + '\n',
forceMoveMarkers: true
}]);
}
}
}
);
if (response) {
const endTime = Date.now();
response.time = endTime - startTime;
handleResponseDisplay(response);
}
} catch (error) {
console.error('Error sending request:', error);
alert(error.message);
} finally {
setIsLoading(false);
}
}, [model, editors, apiClient, handleResponseDisplay]);
const handleResize = useCallback(() => {
Object.values(editors).forEach(editor => {
if (editor) {
editor.layout({ width: 0 });
editor.layout();
}
});
}, [editors]);
// Layout update handler
const handleLayoutUpdate = useCallback(async (finalMessage, layoutDataTree) => {
const prefix = (model.server && model.server.serverOptions)
? await serverManager.renderServerPrefix(model.server.serverOptions, model)
: '';
editors.requestBody?.setValue(prefix + finalMessage);
model.layout.computedLayoutItemRoot = layoutDataTree;
}, [model, editors.requestBody, serverManager]);
// Initialize Monaco editors
useEffect(() => {
if (layoutJsonRef.current && !editors.layoutJson) {
const layoutJson = JSON.stringify(model.layout, null, 2);
const layoutJsonEditor = monaco.editor.create(layoutJsonRef.current, {
value: layoutJson,
language: 'json',
automaticLayout: true,
quickSuggestions: false
});
layoutJsonEditor.onDidChangeModelContent(() => {
try {
const content = layoutJsonEditor.getValue();
JSON.parse(content); // Validate JSON
} catch (e) {
// Invalid JSON - ignore
}
});
setEditors(prev => ({ ...prev, layoutJson: layoutJsonEditor }));
}
if (requestBodyRef.current && !editors.requestBody) {
const requestBodyEditor = monaco.editor.create(requestBodyRef.current, {
value: model.requestBody || '',
language: 'text',
automaticLayout: true,
quickSuggestions: false
});
requestBodyEditor.onDidChangeModelContent(() => {
model.requestBody = requestBodyEditor.getValue();
});
requestBodyEditor.onMouseMove((e) => {
const position = e.target.position;
if (position) {
const editorModel = requestBodyEditor.getModel();
if (!editorModel) return;
const word = editorModel.getWordAtPosition(position);
const lineContent = editorModel.getLineContent(position.lineNumber);
if (word && lineContent.includes(`{{${word.word}}}`)) {
parent?.showPopper?.(e.target.element, word.word);
} else {
parent?.hidePopper?.(e.target.element);
}
}
});
setEditors(prev => ({ ...prev, requestBody: requestBodyEditor }));
}
if (responseBodyRef.current && !editors.responseBody) {
const responseBodyEditor = monaco.editor.create(responseBodyRef.current, {
value: '',
language: 'text',
automaticLayout: true,
quickSuggestions: false,
readOnly: true
});
setEditors(prev => ({ ...prev, responseBody: responseBodyEditor }));
}
if (consoleRef.current && !editors.console) {
const consoleEditor = monaco.editor.create(consoleRef.current, {
value: '',
automaticLayout: true,
quickSuggestions: false,
readOnly: true
});
setEditors(prev => ({ ...prev, console: consoleEditor }));
}
if (preRequestRef.current && !editors.preRequest) {
const preRequestEditor = monaco.editor.create(preRequestRef.current, {
value: model.preRequestScript || '',
language: 'javascript',
automaticLayout: true,
quickSuggestions: false
});
preRequestEditor.onDidChangeModelContent(() => {
model.preRequestScript = preRequestEditor.getValue();
});
setEditors(prev => ({ ...prev, preRequest: preRequestEditor }));
}
if (postRequestRef.current && !editors.postRequest) {
const postRequestEditor = monaco.editor.create(postRequestRef.current, {
value: model.postRequestScript || '',
language: 'javascript',
automaticLayout: true,
quickSuggestions: false
});
postRequestEditor.onDidChangeModelContent(() => {
model.postRequestScript = postRequestEditor.getValue();
});
setEditors(prev => ({ ...prev, postRequest: postRequestEditor }));
}
// Initialize layout data
const layoutRoot = ApiLayoutItemTreeBuilder.createTree(model.layout.layoutItems);
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
const layoutDataRoot = replicator.createReplicatedTree(layoutRoot);
setLayoutData(layoutDataRoot);
return () => {
Object.values(editors).forEach(editor => editor?.dispose());
};
}, []);
// Handle window resize
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
// Tab change handlers
useEffect(() => {
const handleTabChange = (tab) => {
const currentEditor = {
'requestBodyTab': editors.requestBody,
'preRequestTab': editors.preRequest,
'postRequestTab': editors.postRequest
}[tab];
if (currentEditor) {
currentEditor.layout();
}
};
const tabs = ['requestBodyTab', 'preRequestTab', 'postRequestTab'];
tabs.forEach(tab => {
const element = document.querySelector(`#api_request_${model.id} .btn_${tab}`);
if (element) {
const handler = () => handleTabChange(tab);
element.addEventListener('shown.bs.tab', handler);
return () => element.removeEventListener('shown.bs.tab', handler);
}
});
}, [editors, model.id]);
// Server options effect
useEffect(() => {
if (!model.server) return;
if (model.server.headers === undefined) {
model.server.headers = [];
}
if (model.server.serverOptions) {
serverManager.renderTcpServerOptions(
'.server_options',
model.server.serverOptions,
model.headers,
parent
);
}
}, [model.server, model.headers, parent, serverManager]);
return (
<div className="flex flex-col h-full p-4">
{/* Header Section */}
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm text-muted-foreground">
{model.collectionName} &gt; {model.name}
</span>
</div>
{/* Server Selection */}
<div className="flex items-center space-x-2 mb-4">
<div className="form-floating">
{serverManager.renderTcpServers(model.server)}
<label className="text-sm text-muted-foreground">Server</label>
</div>
<Button
onClick={handleSendRequest}
disabled={isLoading}
>
{isLoading ? 'Sending...' : 'Send'}
</Button>
<Button
onClick={() => parent?.handleSaveRequest?.(model)}
disabled={isLoading}
>
Save
</Button>
</div>
{/* Server Options */}
<div className="server_options mb-4" />
{/* Main Content */}
<Tabs defaultValue="layout" className="flex-1">
<TabsList>
<TabsTrigger value="layout">Layout</TabsTrigger>
<TabsTrigger value="layoutJson">Layout JSON</TabsTrigger>
<TabsTrigger value="layoutTree">Layout Tree</TabsTrigger>
<TabsTrigger value="layoutData">Layout Data</TabsTrigger>
<TabsTrigger value="preRequest">Pre-Request</TabsTrigger>
<TabsTrigger value="postRequest">Post-Request</TabsTrigger>
</TabsList>
<TabsContent value="layout">
<div ref={layoutGridRef}>
<LayoutGrid
layout={model.layout}
onChange={(updatedLayout) => {
model.layout = updatedLayout;
editors.layoutJson?.setValue(JSON.stringify(updatedLayout, null, 2));
}}
/>
</div>
</TabsContent>
<TabsContent value="layoutJson">
<div className="flex flex-col gap-4">
<div ref={layoutJsonRef} className="h-[300px] border" />
<Button
onClick={handleLoadLayout}
disabled={isLoading}
>
레이아웃 불러오기
</Button>
</div>
</TabsContent>
<TabsContent value="layoutData">
<div className="flex flex-col gap-4">
<div className="border h-[400px] overflow-auto">
{layoutData && (
<LayoutTreeDataGrid
root={layoutData}
apiRequest={model}
options={layoutDataTreeOptions}
totalMessageLengthSelector={totalMessageLengthRef.current}
onUpdate={handleLayoutUpdate}
/>
)}
</div>
</div>
</TabsContent>
<TabsContent value="preRequest">
<div className="flex gap-4">
<div className="flex-1">
<div ref={preRequestRef} className="h-[300px] border" />
</div>
<div className="w-48">
<VariableSnippet editor={editors.preRequest} />
</div>
</div>
</TabsContent>
<TabsContent value="postRequest">
<div className="flex gap-4">
<div className="flex-1">
<div ref={postRequestRef} className="h-[300px] border" />
</div>
<div className="w-48">
<VariableSnippet editor={editors.postRequest} />
</div>
</div>
</TabsContent>
</Tabs>
{/* Message Length */}
<div className="mt-4 flex items-center space-x-2">
<span>전체 메시지 길이:</span>
<span ref={totalMessageLengthRef} />
</div>
{/* Response Section */}
<Card className="mt-4">
<CardContent>
<div className="flex flex-col">
<div className="flex items-center gap-2 mb-2">
<span>Response</span>
<span className="response_time" />
<span className="response_size" />
</div>
<Tabs defaultValue="body">
<TabsList>
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
<TabsContent value="body">
<div ref={responseBodyRef} className="h-[250px] border" />
</TabsContent>
<TabsContent value="console">
<div ref={consoleRef} className="h-[250px] border" />
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
</div>
);
};
// export default TCPClientView;
@@ -0,0 +1,252 @@
import React, { useState, useCallback } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import EditableInput from '../shared/EditableInput';
const LayoutGrid = ({ layout, onChange }) => {
const [items, setItems] = useState(layout?.layoutItems || []);
const getLastId = useCallback(() => {
let lastId = 0;
items.forEach(item => {
if (item.id > lastId) {
lastId = item.id;
}
});
return lastId + 1;
}, [items]);
const handleAddItem = useCallback(() => {
const newItem = {
id: getLastId(),
parent: 0,
level: 1,
loutName: '',
loutItemName: '',
loutItemDesc: '',
loutItemDepth: 1,
loutItemType: 'Field',
loutItemOccCnt: '',
loutItemOccRef: '',
loutItemDataType: 'String',
loutItemLength: 1,
loutItemDecimal: 0,
loutItemDefault: '',
loutItemMaskYn: 'N',
loutItemMaskOffset: 0,
loutItemMaskLength: 0,
parentLoutItemIndex: 0,
expanded: true,
isLeaf: true,
LOUTITEMPATH: '',
value: '',
loutItemSerno: items.length + 1
};
const newItems = [...items, newItem];
setItems(newItems);
layout.layoutItems = newItems;
onChange?.(layout);
}, [items, layout, onChange, getLastId]);
const handleMove = useCallback((index, direction) => {
if ((direction === 'up' && index > 0) ||
(direction === 'down' && index < items.length - 1)) {
const newItems = [...items];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
[newItems[index], newItems[swapIndex]] = [newItems[swapIndex], newItems[index]];
// Update loutItemSerno values
newItems.forEach((item, idx) => {
item.loutItemSerno = idx + 1;
});
setItems(newItems);
layout.layoutItems = newItems;
onChange?.(layout);
}
}, [items, layout, onChange]);
const handleDelete = useCallback((index) => {
const newItems = items.filter((_, idx) => idx !== index);
// Update loutItemSerno values
newItems.forEach((item, idx) => {
item.loutItemSerno = idx + 1;
});
setItems(newItems);
layout.layoutItems = newItems;
onChange?.(layout);
}, [items, layout, onChange]);
const handleFieldChange = useCallback((index, field, value) => {
const newItems = [...items];
newItems[index][field] = value;
if (field === 'value' && newItems[index].value === undefined) {
newItems[index].value = '';
}
setItems(newItems);
layout.layoutItems = newItems;
onChange?.(layout);
}, [items, layout, onChange]);
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse border">
<thead>
<tr>
<th className="border p-2 text-center w-[50px]">#</th>
<th className="border p-2 text-center w-[80px]">ID</th>
<th className="border p-2 text-center w-[80px]">Parent</th>
<th className="border p-2 text-center w-[120px]">항목명(영문)</th>
<th className="border p-2 text-center w-[150px]">항목설명</th>
<th className="border p-2 text-center w-[60px]">깊이</th>
<th className="border p-2 text-center w-[110px]">아이템 유형</th>
<th className="border p-2 text-center w-[80px]">반복 횟수</th>
<th className="border p-2 text-center w-[100px]">반복 참조 필드</th>
<th className="border p-2 text-center w-[80px]">데이터 길이</th>
<th className="border p-2 text-center w-[120px]"></th>
<th className="border p-2 text-center w-[120px]">
비고
<Button
variant="outline"
size="sm"
onClick={handleAddItem}
className="ml-2"
>
<i className="fas fa-plus" />
</Button>
</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={item.id} className="layout-grid-row">
<td className="border p-2 text-center">
{item.loutItemSerno}
</td>
<td className="border p-2">
<Input
type="text"
value={item.id}
onChange={(e) => handleFieldChange(index, 'id', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="text"
value={item.parent}
onChange={(e) => handleFieldChange(index, 'parent', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="text"
value={item.loutItemName}
onChange={(e) => handleFieldChange(index, 'loutItemName', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="text"
value={item.loutItemDesc}
onChange={(e) => handleFieldChange(index, 'loutItemDesc', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="number"
value={item.loutItemDepth}
onChange={(e) => handleFieldChange(index, 'loutItemDepth', parseInt(e.target.value))}
min={1}
className="w-full"
/>
</td>
<td className="border p-2">
<Select
value={item.loutItemType}
onValueChange={(value) => handleFieldChange(index, 'loutItemType', value)}
className="w-full"
>
<option value="Field">Field</option>
<option value="Grid">Grid</option>
<option value="Group">Group</option>
<option value="Attr">Attr</option>
</Select>
</td>
<td className="border p-2">
<Input
type="text"
value={item.loutItemOccCnt}
onChange={(e) => handleFieldChange(index, 'loutItemOccCnt', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="text"
value={item.loutItemOccRef}
onChange={(e) => handleFieldChange(index, 'loutItemOccRef', e.target.value)}
className="w-full"
/>
</td>
<td className="border p-2">
<Input
type="number"
value={item.loutItemLength}
onChange={(e) => handleFieldChange(index, 'loutItemLength', parseInt(e.target.value))}
min={1}
className="w-full"
/>
</td>
<td className="border p-2">
{item.loutItemType === 'Field' && (
<EditableInput
name={`items[${index}].value`}
value={item.value}
onChange={(value) => handleFieldChange(index, 'value', value)}
/>
)}
</td>
<td className="border p-2 text-center">
<div className="flex justify-center space-x-1">
<Button
variant="outline"
size="sm"
onClick={() => handleMove(index, 'up')}
>
<i className="fas fa-arrow-up" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleMove(index, 'down')}
>
<i className="fas fa-arrow-down" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(index)}
>
<i className="fas fa-trash-alt" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default LayoutGrid;
@@ -0,0 +1,214 @@
import React, { useState, useEffect, useCallback } from 'react';
import { cn } from "@/lib/utils";
import EditableInput from '../shared/EditableInput';
import APIClient from '@/lib/api-client';
import _ from 'lodash';
// Helper function to get indentation for tree visualization
const getIndent = (depth) => {
return Array(depth + 1).fill().map((_, i) => (
<span key={i} className="treegrid-indent" />
));
};
const TreeNode = ({
node,
depth,
options,
isFirst,
onExpand,
expanded,
onValueChange,
getNodePath
}) => {
const hasChildren = node.children && node.children.length > 0;
const renderCell = (fieldInfo) => {
const padding = fieldInfo.isFirst ? getIndent(depth) : null;
const nodePath = getNodePath(node.id) + '.value';
if (node.loutItemType === 'Field' && fieldInfo.type === 'textfield') {
return (
<EditableInput
name={fieldInfo.name}
value={node[fieldInfo.name] || ''}
onChange={(value) => onValueChange(nodePath, value)}
/>
);
}
return (
<div className="flex items-center">
{padding}
{fieldInfo.isFirst && hasChildren && (
<button
onClick={() => onExpand(node.id)}
className="treegrid-expander w-4 h-4 flex items-center justify-center"
>
{expanded ? '-' : '+'}
</button>
)}
{node[fieldInfo.name]}
</div>
);
};
return (
<tr className={`treegrid-${node.id} ${node.parent ? `treegrid-parent-${node.parent}` : ''}`}>
{options.map((fieldInfo, index) => (
<td
key={fieldInfo.name}
className={cn(
"border p-2",
fieldInfo.align === 'center' && "text-center",
fieldInfo.align === 'right' && "text-right"
)}
style={{ width: fieldInfo.width }}
>
{renderCell(fieldInfo)}
</td>
))}
</tr>
);
};
const LayoutTreeDataGrid = ({
root,
apiRequest,
options,
totalMessageLengthSelector,
onUpdate
}) => {
const [expandedNodes, setExpandedNodes] = useState(new Set());
const [apiClient] = useState(() => new APIClient());
const getNodePath = useCallback((targetId, node = root, path = '') => {
if (node.id === targetId) return path;
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
const currentPath = path ? `${path}.children[${i}]` : `children[${i}]`;
const resultPath = getNodePath(targetId, node.children[i], currentPath);
if (resultPath) return resultPath;
}
}
return null;
}, [root]);
const handleExpand = useCallback((nodeId) => {
setExpandedNodes(prev => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}, []);
const fillPadding = useCallback(async (value, dataLength) => {
let paddedValue = await apiClient.processPreScriptAndVariables(apiRequest, value.toString());
while (paddedValue.length < dataLength) {
paddedValue = ' ' + paddedValue;
}
return paddedValue;
}, [apiRequest]);
const concatenateFieldValues = useCallback(async (node) => {
let concatenatedValues = '';
if (node.loutItemType === 'Field') {
concatenatedValues += await fillPadding(node.value || '', node.loutItemLength) || '';
}
if (node.children?.length > 0) {
for (const child of node.children) {
concatenatedValues += await concatenateFieldValues(child);
}
}
return concatenatedValues;
}, [fillPadding]);
const sumFieldLoutItemLengths = useCallback(() => {
const queue = [root];
let sum = 0;
while (queue.length > 0) {
const node = queue.shift();
if (node.loutItemType === 'Field') {
sum += parseInt(node.loutItemLength) || 0;
}
if (node.children?.length > 0) {
queue.push(...node.children);
}
}
return sum;
}, [root]);
const handleValueChange = useCallback(async (path, value) => {
_.set(root, path, value);
const finalMessage = await concatenateFieldValues(root);
onUpdate?.(finalMessage, root);
}, [root, concatenateFieldValues, onUpdate]);
useEffect(() => {
if (totalMessageLengthSelector) {
totalMessageLengthSelector.textContent = sumFieldLoutItemLengths();
}
}, [root, sumFieldLoutItemLengths, totalMessageLengthSelector]);
const renderNodes = useCallback((node, depth = 0) => {
const isExpanded = expandedNodes.has(node.id);
const result = [];
result.push(
<TreeNode
key={node.id}
node={node}
depth={depth}
options={options}
isFirst={true}
onExpand={handleExpand}
expanded={isExpanded}
onValueChange={handleValueChange}
getNodePath={getNodePath}
/>
);
if (isExpanded && node.children?.length > 0) {
node.children.forEach(child => {
result.push(...renderNodes(child, depth + 1));
});
}
return result;
}, [expandedNodes, options, handleExpand, handleValueChange, getNodePath]);
return (
<div className="overflow-auto">
<table className="w-full border-collapse border">
<thead>
<tr>
{options.map(field => (
<th
key={field.name}
className="border p-2 text-center"
style={{ width: field.width }}
>
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
{renderNodes(root)}
</tbody>
</table>
</div>
);
};
export default LayoutTreeDataGrid;
@@ -0,0 +1,158 @@
import React, { useState, useCallback } from 'react';
import { cn } from "@/lib/utils";
import EditableInput from '../shared/EditableInput';
const LayoutTreeGrid = ({ root, options = [], onUpdate }) => {
const [expandedNodes, setExpandedNodes] = useState(new Set());
// Helper function to get node path
const getNodePath = useCallback((node, targetId, path = '') => {
if (node.id === targetId) {
return path;
}
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
const currentPath = path ? `${path}.children[${i}]` : `children[${i}]`;
const resultPath = getNodePath(node.children[i], targetId, currentPath);
if (resultPath) {
return resultPath;
}
}
}
return null;
}, []);
// Get indentation for tree structure
const getIndent = useCallback((depth) => {
return Array(depth + 1).join(' ');
}, []);
// Handle expand/collapse
const handleExpanderClick = useCallback((nodeId) => {
setExpandedNodes(prev => {
const newSet = new Set(prev);
if (newSet.has(nodeId)) {
newSet.delete(nodeId);
} else {
newSet.add(nodeId);
}
return newSet;
});
}, []);
// Render cell content
const renderCell = useCallback((node, field, depth) => {
const nodePath = getNodePath(root, node.id) + '.value';
if (node.loutItemType === 'Field' && field.type === 'textfield') {
return (
<EditableInput
name={field.name}
value={node[field.name]}
path={nodePath}
onChange={(value) => {
node[field.name] = value;
onUpdate?.(root);
}}
/>
);
}
if (field.isFirst) {
const hasChildren = node.children?.length > 0;
return (
<div className="flex items-center">
<span className="inline-block" style={{ width: `${depth * 20}px` }} />
{hasChildren && (
<button
onClick={() => handleExpanderClick(node.id)}
className="w-4 h-4 flex items-center justify-center mr-1"
>
{expandedNodes.has(node.id) ? '' : '+'}
</button>
)}
<span>{node[field.name]}</span>
</div>
);
}
return node[field.name];
}, [root, expandedNodes, handleExpanderClick, getNodePath, onUpdate]);
// Recursive function to render tree rows
const renderTreeRows = useCallback((node, depth = 0, parentId = null) => {
const rows = [];
const isVisible = depth === 0 || !parentId || expandedNodes.has(parentId);
if (!isVisible) {
return rows;
}
const row = (
<tr
key={node.id}
className={cn(
"layout-grid-row",
depth === 0 && "hidden",
parentId && `treegrid-parent-${parentId}`
)}
data-node-id={node.id}
>
{options.map((field, fieldIndex) => (
<td
key={fieldIndex}
className={cn(
"border p-2",
field.align && `text-${field.align}`,
"whitespace-nowrap"
)}
style={{ width: field.width }}
>
{renderCell(node, field, depth)}
</td>
))}
</tr>
);
rows.push(row);
if (node.children?.length > 0) {
node.children.forEach(child => {
rows.push(...renderTreeRows(child, depth + 1, node.id));
});
}
return rows;
}, [options, expandedNodes, renderCell]);
return (
<div className="overflow-auto">
<table className="w-full border-collapse border">
<thead>
<tr>
{options.map((field, index) => (
<th
key={index}
className={cn(
"border p-2",
field.align && `text-${field.align}`,
"whitespace-nowrap"
)}
style={{ width: field.width }}
>
{field.label}
</th>
))}
</tr>
</thead>
<tbody>
{renderTreeRows(root)}
</tbody>
</table>
</div>
);
};
export default LayoutTreeGrid;
@@ -0,0 +1,194 @@
import React, { useState, useRef, useEffect } from 'react';
import { cn } from "@/lib/utils";
const EditableInput = ({
name = '',
value = '',
className = '',
style = {},
onChange = () => {},
onVariableHover = () => {}
}) => {
const contentRef = useRef(null);
const [internalValue, setInternalValue] = useState(value);
const [selection, setSelection] = useState({ start: 0, end: 0 });
// Get current cursor position
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(contentRef.current);
preCaretRange.setEnd(range.endContainer, range.endOffset);
const start = preCaretRange.toString().length;
preCaretRange.setEnd(range.startContainer, range.startOffset);
const end = preCaretRange.toString().length;
return { start, end };
}
return null;
};
// Restore cursor position
const restoreSelection = (pos) => {
if (!pos || !contentRef.current) return;
const sel = window.getSelection();
const range = document.createRange();
let currentPos = 0;
let startNode = null;
let startOffset = 0;
let endNode = null;
let endOffset = 0;
const traverse = (node) => {
if (node.nodeType === 3) { // Text node
const nextPos = currentPos + node.length;
if (!startNode && pos.start >= currentPos && pos.start <= nextPos) {
startNode = node;
startOffset = pos.start - currentPos;
}
if (!endNode && pos.end >= currentPos && pos.end <= nextPos) {
endNode = node;
endOffset = pos.end - currentPos;
}
currentPos = nextPos;
} else {
for (const child of node.childNodes) {
traverse(child);
}
}
};
traverse(contentRef.current);
if (startNode && endNode) {
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
sel.removeAllRanges();
sel.addRange(range);
}
};
// Parse content to find variables
const parseContent = (text) => {
const regex = /{{.*?}}/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: 'text',
content: text.slice(lastIndex, match.index)
});
}
parts.push({
type: 'variable',
content: match[0]
});
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({
type: 'text',
content: text.slice(lastIndex)
});
}
return parts;
};
const handleInput = (e) => {
const newValue = e.target.textContent;
const newSelection = saveSelection();
setInternalValue(newValue);
setSelection(newSelection);
onChange(newValue);
};
const handlePaste = (e) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const processedContent = parseContent(text);
const fragment = document.createDocumentFragment();
processedContent.forEach(({ type, content }) => {
const span = document.createElement('span');
span.textContent = content;
if (type === 'variable') {
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
}
fragment.appendChild(span);
});
range.insertNode(fragment);
handleInput({ target: contentRef.current });
}
};
useEffect(() => {
if (!contentRef.current) return;
const parts = parseContent(internalValue);
const currentSelection = saveSelection() || selection;
contentRef.current.innerHTML = '';
parts.forEach(({ type, content }) => {
const span = document.createElement('span');
span.textContent = content;
if (type === 'variable') {
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
span.onmouseover = () => onVariableHover(content);
}
contentRef.current.appendChild(span);
});
restoreSelection(currentSelection);
}, [internalValue]);
// Update internal value when prop value changes
useEffect(() => {
if (value !== internalValue) {
setInternalValue(value);
}
}, [value]);
return (
<div
ref={contentRef}
contentEditable
className={cn(
"min-h-[40px] rounded-md border border-input bg-background px-3 py-2",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"transition-colors",
className
)}
style={style}
onInput={handleInput}
onPaste={handlePaste}
data-value={internalValue}
data-name={name}
suppressContentEditableWarning={true}
/>
);
};
export default EditableInput;
@@ -0,0 +1,33 @@
// /components/ui/error-alert-dialog.jsx
import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const ErrorAlertDialog = ({ open, onOpenChange, title, description }) => {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title || '오류'}</AlertDialogTitle>
<AlertDialogDescription>
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => onOpenChange(false)}>
확인
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ErrorAlertDialog;
@@ -0,0 +1,162 @@
import React, { useEffect, useRef, memo } from 'react';
import * as monaco from 'monaco-editor';
const MonacoEditor = memo(({
id,
value = '',
language = 'javascript',
readOnly = false,
height = '64',
onChange = () => {}
}) => {
const editorRef = useRef(null);
const containerRef = useRef(null);
const subscriptionRef = useRef(null);
// Configure editor options based on language
const getEditorOptions = (lang) => {
const baseOptions = {
automaticLayout: true,
theme: 'vs-light',
minimap: { enabled: false },
readOnly,
scrollBeyondLastLine: false,
fontSize: 14,
lineHeight: 21,
padding: { top: 8, bottom: 8 },
tabSize: 2,
scrollbar: {
vertical: 'auto',
horizontal: 'auto'
}
};
// Language-specific options
switch (lang) {
case 'json':
return {
...baseOptions,
formatOnPaste: true,
formatOnType: true,
autoClosingBrackets: 'always',
autoClosingQuotes: 'always',
bracketPairColorization: { enabled: true }
};
case 'xml':
return {
...baseOptions,
formatOnType: true,
autoClosingTags: true,
autoClosingBrackets: 'always',
autoClosingQuotes: 'always'
};
default:
return baseOptions;
}
};
// Initialize or update editor
const setupEditor = () => {
if (!containerRef.current) return;
// Dispose existing editor if it exists
if (editorRef.current) {
editorRef.current.dispose();
}
// Create new editor with language-specific options
const options = getEditorOptions(language);
editorRef.current = monaco.editor.create(containerRef.current, {
value,
language,
...options
});
// Set up change event handler
if (subscriptionRef.current) {
subscriptionRef.current.dispose();
}
subscriptionRef.current = editorRef.current.onDidChangeModelContent(() => {
const newValue = editorRef.current.getValue();
onChange(newValue);
});
// Format document if it's JSON
if (language === 'json' && value) {
try {
const modelUri = monaco.Uri.parse(`inmemory://model-${id}.json`);
const model = monaco.editor.createModel(value, 'json', modelUri);
setTimeout(() => {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: false,
schemas: []
});
editorRef.current.setModel(model);
}, 100);
} catch (e) {
console.warn('Failed to format JSON:', e);
}
}
};
// Initial setup
useEffect(() => {
setupEditor();
// Set up resize observer
const resizeObserver = new ResizeObserver(() => {
if (editorRef.current) {
editorRef.current.layout();
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
// Cleanup
return () => {
resizeObserver.disconnect();
if (subscriptionRef.current) {
subscriptionRef.current.dispose();
}
if (editorRef.current) {
editorRef.current.dispose();
}
};
}, [language]); // Reinitialize when language changes
// Update value if it changes externally
useEffect(() => {
if (editorRef.current && value !== editorRef.current.getValue()) {
editorRef.current.setValue(value);
// Add formatting for JSON
if (language === 'json' && value) {
try {
const action = editorRef.current.getAction('editor.action.formatDocument');
if (action) {
action.run();
}
} catch (e) {
console.warn('Failed to format JSON:', e);
}
}
}
}, [value, language]);
return (
<div
id={id}
ref={containerRef}
className={`h-${height} border mt-2`}
style={{ minHeight: '100px' }}
/>
);
});
MonacoEditor.displayName = 'MonacoEditor';
export default MonacoEditor;
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { X } from "lucide-react";
const PropertyTable = ({
properties = [],
onChange,
propertyName = ''
}) => {
const [localProperties, setLocalProperties] = useState(properties);
// Handle property changes without causing infinite loops
const handlePropertyChange = (index, field, value) => {
const updatedProperties = localProperties.map((prop, i) => {
if (i === index) {
return { ...prop, [field]: value };
}
return prop;
});
setLocalProperties(updatedProperties);
onChange?.(updatedProperties);
};
// Add new property
const handleAdd = () => {
const newProperty = {
enabled: true,
key: '',
value: ''
};
const updatedProperties = [...localProperties, newProperty];
setLocalProperties(updatedProperties);
onChange?.(updatedProperties);
};
// Remove property
const handleRemove = (index) => {
const updatedProperties = localProperties.filter((_, i) => i !== index);
setLocalProperties(updatedProperties);
onChange?.(updatedProperties);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">{propertyName}</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14">Enable</TableHead>
<TableHead>Key</TableHead>
<TableHead>Value</TableHead>
<TableHead className="w-14">
<Button
onClick={handleAdd}
variant="outline"
size="sm"
className="w-20"
>
Add
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{localProperties.map((property, index) => (
<TableRow key={index}>
<TableCell className="">
<Checkbox
checked={property.enabled}
onCheckedChange={(checked) =>
handlePropertyChange(index, 'enabled', checked)
}
/>
</TableCell>
<TableCell>
<Input
value={property.key}
onChange={(e) =>
handlePropertyChange(index, 'key', e.target.value)
}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={property.value}
onChange={(e) =>
handlePropertyChange(index, 'value', e.target.value)
}
className="w-full"
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemove(index)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
export default PropertyTable;
@@ -0,0 +1,79 @@
import React from 'react';
import { Button } from "@/components/ui/button";
// Predefined commands/snippets
const commands = [
{
name: 'set_global',
displayName: 'Set Global Variable',
text: 'globals["key"] = "value";\n'
},
{
name: 'get_global',
displayName: 'Get Global Variable',
text: 'const value = globals["key"];\n'
},
{
name: 'set_variable',
displayName: 'Set Variable',
text: 'variables["key"] = "value";\n'
},
{
name: 'get_variable',
displayName: 'Get Variable',
text: 'const value = variables["key"];\n'
},
{
name: 'get_response_body',
displayName: 'Get Response Body',
text: 'const body = response.body;\n'
},
{
name: 'parse_response_body',
displayName: 'Parse Response Body',
text: 'JSON.parse(response.body);\n'
},
{
name: 'get_response_headers',
displayName: 'Get Response Header',
text: 'const header_value = response.headers["header-key"];\n'
}
];
const VariableSnippet = ({ editor }) => {
const appendTextToEditor = (text) => {
if (!editor) return;
const model = editor.getModel();
const position = editor.getPosition();
editor.executeEdits('', [{
range: new monaco.Range(
position.lineNumber,
position.column,
position.lineNumber,
position.column
),
text: text,
forceMoveMarkers: true
}]);
editor.focus();
};
return (
<div className="flex flex-col gap-1">
{commands.map((command) => (
<Button
key={command.name}
variant="link"
size="sm"
className="justify-start h-8 text-sm px-2"
onClick={() => appendTextToEditor(command.text)}
>
{command.displayName}
</Button>
))}
</div>
);
};
export default VariableSnippet;
@@ -0,0 +1,97 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props} />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+94
View File
@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+178
View File
@@ -0,0 +1,178 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props} />
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props} />
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef((
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</MenubarPrimitive.Portal>
))
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
@@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+119
View File
@@ -0,0 +1,119 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
@@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+108
View File
@@ -0,0 +1,108 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+619
View File
@@ -0,0 +1,619 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef((
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [setOpenProp, open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
(<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>)
);
})
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef((
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
(<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}>
{children}
</div>)
);
}
if (isMobile) {
return (
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>)
);
}
return (
(<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} />
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>)
);
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>)
);
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />)
);
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
return (
(<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props} />)
);
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props} />)
);
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} />)
);
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />)
);
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />)
);
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
(<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props} />)
);
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} />
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef((
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
(<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>)
);
})
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props} />)
);
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, [])
return (
(<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>)
);
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef(
({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
}
)
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }
+86
View File
@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,26 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }