외부호출 노드들
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 스크립트 실행 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2, Terminal, FileCode, Settings, Play } from "lucide-react";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ScriptActionNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ScriptActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ScriptActionNodeData;
|
||||
}
|
||||
|
||||
export function ScriptActionProperties({ nodeId, data }: ScriptActionPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
// 로컬 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "스크립트 실행");
|
||||
const [scriptType, setScriptType] = useState<ScriptActionNodeData["scriptType"]>(data.scriptType || "python");
|
||||
const [executionMode, setExecutionMode] = useState<"inline" | "file">(data.executionMode || "inline");
|
||||
const [inlineScript, setInlineScript] = useState(data.inlineScript || "");
|
||||
const [scriptPath, setScriptPath] = useState(data.scriptPath || "");
|
||||
const [executablePath, setExecutablePath] = useState(data.executablePath || "");
|
||||
const [inputMethod, setInputMethod] = useState<ScriptActionNodeData["inputMethod"]>(data.inputMethod || "stdin");
|
||||
const [inputFormat, setInputFormat] = useState<"json" | "csv" | "text">(data.inputFormat || "json");
|
||||
const [workingDirectory, setWorkingDirectory] = useState(data.workingDirectory || "");
|
||||
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "60000");
|
||||
const [maxBuffer, setMaxBuffer] = useState(data.options?.maxBuffer?.toString() || "1048576");
|
||||
const [shell, setShell] = useState(data.options?.shell || "");
|
||||
const [captureStdout, setCaptureStdout] = useState(data.outputHandling?.captureStdout ?? true);
|
||||
const [captureStderr, setCaptureStderr] = useState(data.outputHandling?.captureStderr ?? true);
|
||||
const [parseOutput, setParseOutput] = useState<"json" | "lines" | "text">(data.outputHandling?.parseOutput || "text");
|
||||
|
||||
// 환경변수
|
||||
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
|
||||
Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value }))
|
||||
);
|
||||
|
||||
// 명령줄 인자
|
||||
const [args, setArgs] = useState<string[]>(data.arguments || []);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "스크립트 실행");
|
||||
setScriptType(data.scriptType || "python");
|
||||
setExecutionMode(data.executionMode || "inline");
|
||||
setInlineScript(data.inlineScript || "");
|
||||
setScriptPath(data.scriptPath || "");
|
||||
setExecutablePath(data.executablePath || "");
|
||||
setInputMethod(data.inputMethod || "stdin");
|
||||
setInputFormat(data.inputFormat || "json");
|
||||
setWorkingDirectory(data.workingDirectory || "");
|
||||
setTimeout(data.options?.timeout?.toString() || "60000");
|
||||
setMaxBuffer(data.options?.maxBuffer?.toString() || "1048576");
|
||||
setShell(data.options?.shell || "");
|
||||
setCaptureStdout(data.outputHandling?.captureStdout ?? true);
|
||||
setCaptureStderr(data.outputHandling?.captureStderr ?? true);
|
||||
setParseOutput(data.outputHandling?.parseOutput || "text");
|
||||
setEnvVars(Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value })));
|
||||
setArgs(data.arguments || []);
|
||||
}, [data]);
|
||||
|
||||
// 노드 업데이트 함수
|
||||
const updateNodeData = useCallback(
|
||||
(updates: Partial<ScriptActionNodeData>) => {
|
||||
updateNode(nodeId, {
|
||||
...data,
|
||||
...updates,
|
||||
});
|
||||
},
|
||||
[nodeId, data, updateNode]
|
||||
);
|
||||
|
||||
// 표시명 변경
|
||||
const handleDisplayNameChange = (value: string) => {
|
||||
setDisplayName(value);
|
||||
updateNodeData({ displayName: value });
|
||||
};
|
||||
|
||||
// 스크립트 타입 변경
|
||||
const handleScriptTypeChange = (value: ScriptActionNodeData["scriptType"]) => {
|
||||
setScriptType(value);
|
||||
updateNodeData({ scriptType: value });
|
||||
};
|
||||
|
||||
// 실행 모드 변경
|
||||
const handleExecutionModeChange = (value: "inline" | "file") => {
|
||||
setExecutionMode(value);
|
||||
updateNodeData({ executionMode: value });
|
||||
};
|
||||
|
||||
// 스크립트 내용 업데이트
|
||||
const updateScriptContent = useCallback(() => {
|
||||
updateNodeData({
|
||||
inlineScript,
|
||||
scriptPath,
|
||||
executablePath,
|
||||
});
|
||||
}, [inlineScript, scriptPath, executablePath, updateNodeData]);
|
||||
|
||||
// 입력 설정 업데이트
|
||||
const updateInputSettings = useCallback(() => {
|
||||
updateNodeData({
|
||||
inputMethod,
|
||||
inputFormat,
|
||||
workingDirectory: workingDirectory || undefined,
|
||||
});
|
||||
}, [inputMethod, inputFormat, workingDirectory, updateNodeData]);
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOptions = useCallback(() => {
|
||||
updateNodeData({
|
||||
options: {
|
||||
timeout: parseInt(timeout) || 60000,
|
||||
maxBuffer: parseInt(maxBuffer) || 1048576,
|
||||
shell: shell || undefined,
|
||||
},
|
||||
});
|
||||
}, [timeout, maxBuffer, shell, updateNodeData]);
|
||||
|
||||
// 출력 처리 업데이트
|
||||
const updateOutputHandling = useCallback(() => {
|
||||
updateNodeData({
|
||||
outputHandling: {
|
||||
captureStdout,
|
||||
captureStderr,
|
||||
parseOutput,
|
||||
},
|
||||
});
|
||||
}, [captureStdout, captureStderr, parseOutput, updateNodeData]);
|
||||
|
||||
// 환경변수 추가
|
||||
const addEnvVar = () => {
|
||||
const newEnvVars = [...envVars, { key: "", value: "" }];
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
|
||||
// 환경변수 삭제
|
||||
const removeEnvVar = (index: number) => {
|
||||
const newEnvVars = envVars.filter((_, i) => i !== index);
|
||||
setEnvVars(newEnvVars);
|
||||
const envObj = Object.fromEntries(newEnvVars.filter(e => e.key).map(e => [e.key, e.value]));
|
||||
updateNodeData({ environmentVariables: envObj });
|
||||
};
|
||||
|
||||
// 환경변수 업데이트
|
||||
const updateEnvVar = (index: number, field: "key" | "value", value: string) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index][field] = value;
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
|
||||
// 환경변수 저장
|
||||
const saveEnvVars = () => {
|
||||
const envObj = Object.fromEntries(envVars.filter(e => e.key).map(e => [e.key, e.value]));
|
||||
updateNodeData({ environmentVariables: envObj });
|
||||
};
|
||||
|
||||
// 인자 추가
|
||||
const addArg = () => {
|
||||
const newArgs = [...args, ""];
|
||||
setArgs(newArgs);
|
||||
};
|
||||
|
||||
// 인자 삭제
|
||||
const removeArg = (index: number) => {
|
||||
const newArgs = args.filter((_, i) => i !== index);
|
||||
setArgs(newArgs);
|
||||
updateNodeData({ arguments: newArgs });
|
||||
};
|
||||
|
||||
// 인자 업데이트
|
||||
const updateArg = (index: number, value: string) => {
|
||||
const newArgs = [...args];
|
||||
newArgs[index] = value;
|
||||
setArgs(newArgs);
|
||||
};
|
||||
|
||||
// 인자 저장
|
||||
const saveArgs = () => {
|
||||
updateNodeData({ arguments: args.filter(a => a) });
|
||||
};
|
||||
|
||||
// 스크립트 타입별 기본 스크립트 템플릿
|
||||
const getScriptTemplate = (type: string) => {
|
||||
switch (type) {
|
||||
case "python":
|
||||
return `import sys
|
||||
import json
|
||||
|
||||
# 입력 데이터 읽기 (stdin)
|
||||
input_data = json.loads(sys.stdin.read())
|
||||
|
||||
# 처리 로직
|
||||
result = {
|
||||
"status": "success",
|
||||
"data": input_data
|
||||
}
|
||||
|
||||
# 결과 출력
|
||||
print(json.dumps(result))`;
|
||||
case "shell":
|
||||
return `#!/bin/bash
|
||||
|
||||
# 입력 데이터 읽기
|
||||
INPUT=$(cat)
|
||||
|
||||
# 처리 로직
|
||||
echo "입력 데이터: $INPUT"
|
||||
|
||||
# 결과 출력
|
||||
echo '{"status": "success"}'`;
|
||||
case "powershell":
|
||||
return `# 입력 데이터 읽기
|
||||
$input = $input | ConvertFrom-Json
|
||||
|
||||
# 처리 로직
|
||||
$result = @{
|
||||
status = "success"
|
||||
data = $input
|
||||
}
|
||||
|
||||
# 결과 출력
|
||||
$result | ConvertTo-Json`;
|
||||
case "node":
|
||||
return `const readline = require('readline');
|
||||
|
||||
let input = '';
|
||||
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// 처리 로직
|
||||
const result = {
|
||||
status: 'success',
|
||||
data: data
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
});`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 표시명 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시명</Label>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
placeholder="스크립트 실행"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 스크립트 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">스크립트 타입</Label>
|
||||
<Select value={scriptType} onValueChange={handleScriptTypeChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
<SelectItem value="shell">Shell (Bash)</SelectItem>
|
||||
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||
<SelectItem value="node">Node.js</SelectItem>
|
||||
<SelectItem value="executable">실행 파일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 실행 모드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">실행 방식</Label>
|
||||
<Select value={executionMode} onValueChange={handleExecutionModeChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inline">인라인 스크립트</SelectItem>
|
||||
<SelectItem value="file">파일 실행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="script" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="script" className="text-xs">
|
||||
<FileCode className="mr-1 h-3 w-3" />
|
||||
스크립트
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="input" className="text-xs">
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
입력
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="env" className="text-xs">
|
||||
<Terminal className="mr-1 h-3 w-3" />
|
||||
환경
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="output" className="text-xs">
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
출력
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 스크립트 탭 */}
|
||||
<TabsContent value="script" className="space-y-3 pt-3">
|
||||
{executionMode === "inline" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">스크립트 코드</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => setInlineScript(getScriptTemplate(scriptType))}
|
||||
>
|
||||
템플릿 삽입
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={inlineScript}
|
||||
onChange={(e) => setInlineScript(e.target.value)}
|
||||
onBlur={updateScriptContent}
|
||||
placeholder="스크립트 코드를 입력하세요..."
|
||||
className="min-h-[250px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{scriptType === "executable" ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">실행 파일 경로</Label>
|
||||
<Input
|
||||
value={executablePath}
|
||||
onChange={(e) => setExecutablePath(e.target.value)}
|
||||
onBlur={updateScriptContent}
|
||||
placeholder="/path/to/executable"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">스크립트 파일 경로</Label>
|
||||
<Input
|
||||
value={scriptPath}
|
||||
onChange={(e) => setScriptPath(e.target.value)}
|
||||
onBlur={updateScriptContent}
|
||||
placeholder={`/path/to/script.${scriptType === "python" ? "py" : scriptType === "shell" ? "sh" : scriptType === "powershell" ? "ps1" : "js"}`}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 명령줄 인자 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">명령줄 인자</Label>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addArg}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{args.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={arg}
|
||||
onChange={(e) => updateArg(index, e.target.value)}
|
||||
onBlur={saveArgs}
|
||||
placeholder={`인자 ${index + 1}`}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeArg(index)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 입력 탭 */}
|
||||
<TabsContent value="input" className="space-y-3 pt-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">입력 전달 방식</Label>
|
||||
<Select value={inputMethod} onValueChange={(v: ScriptActionNodeData["inputMethod"]) => {
|
||||
setInputMethod(v);
|
||||
updateNodeData({ inputMethod: v });
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdin">표준입력 (stdin)</SelectItem>
|
||||
<SelectItem value="args">명령줄 인자</SelectItem>
|
||||
<SelectItem value="env">환경변수</SelectItem>
|
||||
<SelectItem value="file">임시 파일</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(inputMethod === "stdin" || inputMethod === "file") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">입력 형식</Label>
|
||||
<Select value={inputFormat} onValueChange={(v: "json" | "csv" | "text") => {
|
||||
setInputFormat(v);
|
||||
updateNodeData({ inputFormat: v });
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">작업 디렉토리</Label>
|
||||
<Input
|
||||
value={workingDirectory}
|
||||
onChange={(e) => setWorkingDirectory(e.target.value)}
|
||||
onBlur={updateInputSettings}
|
||||
placeholder="/path/to/working/directory"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">타임아웃 (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
onBlur={updateOptions}
|
||||
placeholder="60000"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 환경변수 탭 */}
|
||||
<TabsContent value="env" className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">환경변수</Label>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addEnvVar}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{envVars.map((env, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={env.key}
|
||||
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
|
||||
onBlur={saveEnvVars}
|
||||
placeholder="변수명"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={env.value}
|
||||
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
|
||||
onBlur={saveEnvVars}
|
||||
placeholder="값"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeEnvVar(index)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{envVars.length === 0 && (
|
||||
<Card className="bg-gray-50">
|
||||
<CardContent className="p-3 text-xs text-gray-500">
|
||||
환경변수가 없습니다. 추가 버튼을 클릭하여 환경변수를 설정하세요.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">사용할 쉘</Label>
|
||||
<Input
|
||||
value={shell}
|
||||
onChange={(e) => setShell(e.target.value)}
|
||||
onBlur={updateOptions}
|
||||
placeholder="/bin/bash (기본값 사용 시 비워두기)"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 출력 탭 */}
|
||||
<TabsContent value="output" className="space-y-3 pt-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={captureStdout}
|
||||
onCheckedChange={(checked) => {
|
||||
setCaptureStdout(checked);
|
||||
updateNodeData({
|
||||
outputHandling: { ...data.outputHandling, captureStdout: checked, captureStderr, parseOutput },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xs">표준출력 (stdout) 캡처</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={captureStderr}
|
||||
onCheckedChange={(checked) => {
|
||||
setCaptureStderr(checked);
|
||||
updateNodeData({
|
||||
outputHandling: { ...data.outputHandling, captureStdout, captureStderr: checked, parseOutput },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xs">표준에러 (stderr) 캡처</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">출력 파싱</Label>
|
||||
<Select value={parseOutput} onValueChange={(v: "json" | "lines" | "text") => {
|
||||
setParseOutput(v);
|
||||
updateNodeData({
|
||||
outputHandling: { ...data.outputHandling, captureStdout, captureStderr, parseOutput: v },
|
||||
});
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON 파싱</SelectItem>
|
||||
<SelectItem value="lines">줄 단위 배열</SelectItem>
|
||||
<SelectItem value="text">텍스트 그대로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-50">
|
||||
<CardContent className="p-3 text-xs text-gray-600">
|
||||
<div className="font-medium mb-1">출력 데이터 사용:</div>
|
||||
스크립트의 출력은 다음 노드에서 <code>{"{{scriptOutput}}"}</code>로 참조할 수 있습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user