@doppelgangerdev/doppelganger 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +9 -0
- package/.github/workflows/docker-publish.yml +59 -0
- package/CODE_OF_CONDUCT.md +28 -0
- package/CONTRIBUTING.md +42 -0
- package/Dockerfile +44 -0
- package/LICENSE +163 -0
- package/README.md +133 -0
- package/TERMS.md +16 -0
- package/THIRD_PARTY_LICENSES.md +3502 -0
- package/agent.js +1240 -0
- package/headful.js +171 -0
- package/index.html +21 -0
- package/n8n-nodes-doppelganger/LICENSE +201 -0
- package/n8n-nodes-doppelganger/README.md +42 -0
- package/n8n-nodes-doppelganger/package-lock.json +6128 -0
- package/n8n-nodes-doppelganger/package.json +36 -0
- package/n8n-nodes-doppelganger/src/credentials/DoppelgangerApi.credentials.ts +35 -0
- package/n8n-nodes-doppelganger/src/index.ts +4 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/Doppelganger.node.ts +147 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/icon.png +0 -0
- package/n8n-nodes-doppelganger/tsconfig.json +14 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/public/icon.png +0 -0
- package/public/novnc.html +151 -0
- package/public/styles.css +86 -0
- package/scrape.js +389 -0
- package/server.js +875 -0
- package/src/App.tsx +722 -0
- package/src/components/AuthScreen.tsx +95 -0
- package/src/components/CodeEditor.tsx +70 -0
- package/src/components/DashboardScreen.tsx +133 -0
- package/src/components/EditorScreen.tsx +1519 -0
- package/src/components/ExecutionDetailScreen.tsx +115 -0
- package/src/components/ExecutionsScreen.tsx +156 -0
- package/src/components/LoadingScreen.tsx +26 -0
- package/src/components/NotFoundScreen.tsx +34 -0
- package/src/components/RichInput.tsx +68 -0
- package/src/components/SettingsScreen.tsx +228 -0
- package/src/components/Sidebar.tsx +61 -0
- package/src/components/app/CenterAlert.tsx +44 -0
- package/src/components/app/CenterConfirm.tsx +33 -0
- package/src/components/app/EditorLoader.tsx +89 -0
- package/src/components/editor/ActionPalette.tsx +79 -0
- package/src/components/editor/JsonEditorPane.tsx +71 -0
- package/src/components/editor/ResultsPane.tsx +641 -0
- package/src/components/editor/actionCatalog.ts +23 -0
- package/src/components/settings/AgentAiPanel.tsx +105 -0
- package/src/components/settings/ApiKeyPanel.tsx +68 -0
- package/src/components/settings/CookiesPanel.tsx +154 -0
- package/src/components/settings/LayoutPanel.tsx +46 -0
- package/src/components/settings/ScreenshotsPanel.tsx +64 -0
- package/src/components/settings/SettingsHeader.tsx +28 -0
- package/src/components/settings/StoragePanel.tsx +35 -0
- package/src/index.css +287 -0
- package/src/main.tsx +13 -0
- package/src/types.ts +114 -0
- package/src/utils/syntaxHighlight.ts +140 -0
- package/start-vnc.sh +52 -0
- package/tailwind.config.js +22 -0
- package/tsconfig.json +39 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.mts +27 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Plus, Home, Settings as SettingsIcon, LogOut, List } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface SidebarProps {
|
|
4
|
+
onNavigate: (screen: 'dashboard' | 'editor' | 'settings' | 'executions') => void;
|
|
5
|
+
onNewTask: () => void;
|
|
6
|
+
onLogout: () => void;
|
|
7
|
+
currentScreen: 'dashboard' | 'editor' | 'settings' | 'executions';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Sidebar: React.FC<SidebarProps> = ({ onNavigate, onNewTask, onLogout, currentScreen }) => {
|
|
11
|
+
return (
|
|
12
|
+
<aside className="w-20 h-full border-r border-white/10 glass flex flex-col items-center py-8 shrink-0 z-50">
|
|
13
|
+
<button onClick={() => onNavigate('dashboard')} className="mb-12 hover:opacity-80 transition-opacity">
|
|
14
|
+
<img src="/icon.png" alt="Logo" className="w-9 h-9" onError={(e) => { e.currentTarget.src = '/icon.png' }} />
|
|
15
|
+
</button>
|
|
16
|
+
|
|
17
|
+
<div className="flex-1 flex flex-col gap-6">
|
|
18
|
+
<button
|
|
19
|
+
onClick={onNewTask}
|
|
20
|
+
className="w-12 h-12 rounded-2xl flex items-center justify-center text-white bg-white/5 hover:bg-white/10 transition-all"
|
|
21
|
+
title="New Task"
|
|
22
|
+
>
|
|
23
|
+
<Plus className="w-6 h-6" />
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
<button
|
|
27
|
+
onClick={() => onNavigate('dashboard')}
|
|
28
|
+
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all ${currentScreen === 'dashboard' ? 'bg-white/10 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`}
|
|
29
|
+
title="Dashboard"
|
|
30
|
+
>
|
|
31
|
+
<Home className="w-6 h-6" />
|
|
32
|
+
</button>
|
|
33
|
+
|
|
34
|
+
<button
|
|
35
|
+
onClick={() => onNavigate('settings')}
|
|
36
|
+
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all ${currentScreen === 'settings' ? 'bg-white/10 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`}
|
|
37
|
+
title="Settings"
|
|
38
|
+
>
|
|
39
|
+
<SettingsIcon className="w-6 h-6" />
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
onClick={() => onNavigate('executions')}
|
|
43
|
+
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all ${currentScreen === 'executions' ? 'bg-white/10 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`}
|
|
44
|
+
title="Executions"
|
|
45
|
+
>
|
|
46
|
+
<List className="w-6 h-6" />
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<button
|
|
51
|
+
onClick={onLogout}
|
|
52
|
+
className="w-12 h-12 rounded-2xl flex items-center justify-center text-gray-500 hover:bg-red-500/10 hover:text-red-500 transition-all"
|
|
53
|
+
title="Logout"
|
|
54
|
+
>
|
|
55
|
+
<LogOut className="w-6 h-6" />
|
|
56
|
+
</button>
|
|
57
|
+
</aside>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default Sidebar;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface CenterAlertProps {
|
|
4
|
+
message: string;
|
|
5
|
+
tone?: 'success' | 'error';
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CenterAlert: React.FC<CenterAlertProps> = ({ message, tone, onClose }) => {
|
|
10
|
+
const [closing, setClosing] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const autoTimer = setTimeout(() => {
|
|
14
|
+
setClosing(true);
|
|
15
|
+
}, 2500);
|
|
16
|
+
return () => clearTimeout(autoTimer);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!closing) return;
|
|
21
|
+
const closeTimer = setTimeout(() => onClose(), 240);
|
|
22
|
+
return () => clearTimeout(closeTimer);
|
|
23
|
+
}, [closing, onClose]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={`fixed bottom-6 right-6 z-[220] max-w-sm w-full ${closing ? 'animate-out fade-out slide-out-to-bottom-3 duration-200' : 'animate-in fade-in slide-in-from-bottom-3 duration-300'}`}>
|
|
27
|
+
<div className={`glass-card rounded-2xl border border-white/10 p-4 shadow-2xl flex items-start gap-3 ${closing ? 'animate-out fade-out zoom-out-95 duration-200' : 'animate-in fade-in zoom-in-95 duration-300'}`}>
|
|
28
|
+
<div className={`mt-1 h-2.5 w-2.5 rounded-full ${tone === 'error' ? 'bg-red-400' : 'bg-emerald-400'}`} />
|
|
29
|
+
<div className="flex-1">
|
|
30
|
+
<p className="text-[8px] font-bold uppercase tracking-[0.35em] text-gray-500">Notification</p>
|
|
31
|
+
<p className="mt-2 font-mono text-[11px] text-white leading-relaxed">{message}</p>
|
|
32
|
+
</div>
|
|
33
|
+
<button
|
|
34
|
+
onClick={() => setClosing(true)}
|
|
35
|
+
className="px-2 py-1 text-[8px] font-bold uppercase tracking-[0.2em] text-white/60 hover:text-white"
|
|
36
|
+
>
|
|
37
|
+
Close
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default CenterAlert;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ConfirmRequest } from '../../types';
|
|
2
|
+
|
|
3
|
+
interface CenterConfirmProps {
|
|
4
|
+
request: ConfirmRequest;
|
|
5
|
+
onResolve: (result: boolean) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const CenterConfirm: React.FC<CenterConfirmProps> = ({ request, onResolve }) => {
|
|
9
|
+
return (
|
|
10
|
+
<div className="fixed inset-0 z-[201] flex items-center justify-center bg-black/70 backdrop-blur-sm px-6">
|
|
11
|
+
<div className="glass-card w-full max-w-md rounded-[32px] border border-white/10 p-8 text-center shadow-2xl">
|
|
12
|
+
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-500">{request.title ?? 'Confirm'}</p>
|
|
13
|
+
<p className="mt-4 font-mono text-sm text-white">{request.message}</p>
|
|
14
|
+
<div className="mt-6 flex gap-4">
|
|
15
|
+
<button
|
|
16
|
+
onClick={() => onResolve(false)}
|
|
17
|
+
className="w-full rounded-2xl px-6 py-3 text-[9px] font-bold uppercase tracking-[0.3em] transition-all bg-white/5 border border-white/10 text-gray-300 hover:bg-white/10"
|
|
18
|
+
>
|
|
19
|
+
{request.cancelLabel ?? 'Cancel'}
|
|
20
|
+
</button>
|
|
21
|
+
<button
|
|
22
|
+
onClick={() => onResolve(true)}
|
|
23
|
+
className="w-full rounded-2xl px-6 py-3 text-[9px] font-bold uppercase tracking-[0.3em] transition-all bg-white text-black hover:scale-105 shadow-xl shadow-white/10"
|
|
24
|
+
>
|
|
25
|
+
{request.confirmLabel ?? 'Confirm'}
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default CenterConfirm;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { Task } from '../../types';
|
|
4
|
+
import LoadingScreen from '../LoadingScreen';
|
|
5
|
+
import NotFoundScreen from '../NotFoundScreen';
|
|
6
|
+
import EditorScreen from '../EditorScreen';
|
|
7
|
+
|
|
8
|
+
interface EditorLoaderProps {
|
|
9
|
+
tasks: Task[];
|
|
10
|
+
loadTasks: () => Promise<Task[]>;
|
|
11
|
+
touchTask: (id: string) => void;
|
|
12
|
+
currentTask: Task | null;
|
|
13
|
+
setCurrentTask: (task: Task) => void;
|
|
14
|
+
editorView: any;
|
|
15
|
+
setEditorView: any;
|
|
16
|
+
isExecuting: boolean;
|
|
17
|
+
onSave: () => void;
|
|
18
|
+
onRun: () => void;
|
|
19
|
+
onRunSnapshot?: (task: Task) => void;
|
|
20
|
+
results: any;
|
|
21
|
+
pinnedResults?: any;
|
|
22
|
+
onPinResults?: any;
|
|
23
|
+
onUnpinResults?: any;
|
|
24
|
+
saveMsg: string;
|
|
25
|
+
onConfirm: any;
|
|
26
|
+
onNotify: any;
|
|
27
|
+
runId?: string | null;
|
|
28
|
+
onStop?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EditorLoader: React.FC<EditorLoaderProps> = ({ tasks, loadTasks, touchTask, currentTask, setCurrentTask, ...props }) => {
|
|
32
|
+
const { id } = useParams();
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
const [loading, setLoading] = useState(false);
|
|
35
|
+
const [notFound, setNotFound] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const init = async () => {
|
|
39
|
+
if (currentTask?.id === id) return;
|
|
40
|
+
|
|
41
|
+
setLoading(true);
|
|
42
|
+
setNotFound(false);
|
|
43
|
+
let targetTasks = tasks;
|
|
44
|
+
if (tasks.length === 0) {
|
|
45
|
+
targetTasks = await loadTasks();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const task = targetTasks.find((t: any) => String(t.id) === String(id));
|
|
49
|
+
if (task) {
|
|
50
|
+
const migrated = { ...task };
|
|
51
|
+
if (!migrated.variables || Array.isArray(migrated.variables)) migrated.variables = {};
|
|
52
|
+
if (!migrated.stealth) {
|
|
53
|
+
migrated.stealth = { allowTypos: false, idleMovements: false, overscroll: false, deadClicks: false, fatigue: false, naturalTyping: false };
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(migrated.actions)) {
|
|
56
|
+
migrated.actions = migrated.actions.map((action, index) => {
|
|
57
|
+
if (action && action.id) return action;
|
|
58
|
+
return { ...action, id: `act_${Date.now()}_${index}_${Math.floor(Math.random() * 1000)}` };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (migrated.includeShadowDom === undefined) migrated.includeShadowDom = true;
|
|
62
|
+
setCurrentTask(migrated);
|
|
63
|
+
if (id) touchTask(id);
|
|
64
|
+
} else {
|
|
65
|
+
setNotFound(true);
|
|
66
|
+
}
|
|
67
|
+
setLoading(false);
|
|
68
|
+
};
|
|
69
|
+
init();
|
|
70
|
+
}, [id, tasks]);
|
|
71
|
+
|
|
72
|
+
if (notFound) {
|
|
73
|
+
return (
|
|
74
|
+
<NotFoundScreen
|
|
75
|
+
title="Task Not Found"
|
|
76
|
+
subtitle="This task does not exist or was deleted."
|
|
77
|
+
onBack={() => navigate('/dashboard')}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (loading || !currentTask || String(currentTask.id) !== String(id)) {
|
|
83
|
+
return <LoadingScreen title="Loading Mission Data" subtitle="Syncing task payload" />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return <EditorScreen currentTask={currentTask} setCurrentTask={setCurrentTask} {...props} />;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default EditorLoader;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { X } from 'lucide-react';
|
|
2
|
+
import { useMemo, useRef } from 'react';
|
|
3
|
+
import { Action } from '../../types';
|
|
4
|
+
import { ACTION_CATALOG } from './actionCatalog';
|
|
5
|
+
|
|
6
|
+
interface ActionPaletteProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
query: string;
|
|
9
|
+
onQueryChange: (value: string) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
onSelect: (type: Action['type']) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ActionPalette: React.FC<ActionPaletteProps> = ({ open, query, onQueryChange, onClose, onSelect }) => {
|
|
15
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
16
|
+
|
|
17
|
+
const filtered = useMemo(() => {
|
|
18
|
+
const q = query.trim().toLowerCase();
|
|
19
|
+
if (!q) return ACTION_CATALOG;
|
|
20
|
+
return ACTION_CATALOG.filter((item) =>
|
|
21
|
+
item.label.toLowerCase().includes(q) || item.description.toLowerCase().includes(q)
|
|
22
|
+
);
|
|
23
|
+
}, [query]);
|
|
24
|
+
|
|
25
|
+
if (!open) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="fixed inset-0 z-[190] flex items-center justify-center bg-black/70 backdrop-blur-sm px-6"
|
|
30
|
+
onClick={onClose}
|
|
31
|
+
>
|
|
32
|
+
<div
|
|
33
|
+
className="glass-card w-full max-w-xl rounded-[28px] border border-white/10 p-6 shadow-2xl"
|
|
34
|
+
onClick={(e) => e.stopPropagation()}
|
|
35
|
+
>
|
|
36
|
+
<div className="flex items-center justify-between mb-4">
|
|
37
|
+
<div>
|
|
38
|
+
<p className="text-[9px] font-bold uppercase tracking-[0.4em] text-gray-500">Add Block</p>
|
|
39
|
+
<p className="text-xs text-gray-400 mt-1">Search actions and control flow blocks.</p>
|
|
40
|
+
</div>
|
|
41
|
+
<button
|
|
42
|
+
onClick={onClose}
|
|
43
|
+
className="p-2 rounded-xl border border-white/10 text-white/70 hover:text-white hover:bg-white/5 transition-all"
|
|
44
|
+
aria-label="Close"
|
|
45
|
+
>
|
|
46
|
+
<X className="w-4 h-4" />
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
<input
|
|
50
|
+
ref={(node) => {
|
|
51
|
+
inputRef.current = node;
|
|
52
|
+
if (node) node.focus();
|
|
53
|
+
}}
|
|
54
|
+
value={query}
|
|
55
|
+
onChange={(e) => onQueryChange(e.target.value)}
|
|
56
|
+
placeholder="Type to filter (e.g., if, click, loop)"
|
|
57
|
+
className="w-full rounded-xl bg-black/40 border border-white/10 px-4 py-3 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30"
|
|
58
|
+
/>
|
|
59
|
+
<div className="mt-4 max-h-[320px] overflow-y-auto custom-scrollbar space-y-2">
|
|
60
|
+
{filtered.map((item) => (
|
|
61
|
+
<button
|
|
62
|
+
key={item.type}
|
|
63
|
+
onClick={() => onSelect(item.type)}
|
|
64
|
+
className="w-full text-left px-4 py-3 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.06] transition-all"
|
|
65
|
+
>
|
|
66
|
+
<div className="text-[10px] font-bold uppercase tracking-widest text-white">{item.label}</div>
|
|
67
|
+
<div className="text-[9px] text-gray-500 mt-1">{item.description}</div>
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
{filtered.length === 0 && (
|
|
71
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">No matches.</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export default ActionPalette;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Check, Copy } from 'lucide-react';
|
|
3
|
+
import { Task } from '../../types';
|
|
4
|
+
import CodeEditor from '../CodeEditor';
|
|
5
|
+
|
|
6
|
+
interface JsonEditorPaneProps {
|
|
7
|
+
task: Task;
|
|
8
|
+
onChange: (task: Task) => void;
|
|
9
|
+
onCopy: (text: string, id: string) => void;
|
|
10
|
+
copiedId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const JsonEditorPane: React.FC<JsonEditorPaneProps> = ({ task, onChange, onCopy, copiedId }) => {
|
|
14
|
+
const [draft, setDraft] = useState(() => JSON.stringify(task, null, 2));
|
|
15
|
+
const [dirty, setDirty] = useState(false);
|
|
16
|
+
|
|
17
|
+
const normalizeTask = (raw: any): Task => {
|
|
18
|
+
const base = task;
|
|
19
|
+
const merged: Task = { ...base, ...(raw && typeof raw === 'object' ? raw : {}) } as Task;
|
|
20
|
+
if (!merged.name || typeof merged.name !== 'string') merged.name = base.name || 'Task';
|
|
21
|
+
if (!merged.mode || !['scrape', 'agent', 'headful'].includes(merged.mode)) merged.mode = base.mode || 'agent';
|
|
22
|
+
if (typeof merged.wait !== 'number') merged.wait = typeof base.wait === 'number' ? base.wait : 3;
|
|
23
|
+
if (!merged.stealth) merged.stealth = base.stealth;
|
|
24
|
+
if (!merged.variables || Array.isArray(merged.variables)) merged.variables = base.variables || {};
|
|
25
|
+
if (!Array.isArray(merged.actions)) merged.actions = base.actions || [];
|
|
26
|
+
if (!merged.extractionFormat) merged.extractionFormat = base.extractionFormat || 'json';
|
|
27
|
+
if (merged.includeShadowDom === undefined) merged.includeShadowDom = base.includeShadowDom ?? true;
|
|
28
|
+
if (base.id && merged.id !== base.id) merged.id = base.id;
|
|
29
|
+
delete (merged as any).versions;
|
|
30
|
+
delete (merged as any).last_opened;
|
|
31
|
+
return merged;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (dirty) return;
|
|
36
|
+
setDraft(JSON.stringify(task, null, 2));
|
|
37
|
+
}, [task, dirty]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="h-full flex flex-col min-h-0">
|
|
41
|
+
<div className="flex items-center justify-between mb-3">
|
|
42
|
+
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest">Protocol JSON</span>
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => { onCopy(JSON.stringify(task, null, 2), 'json'); }}
|
|
45
|
+
className={`px-4 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 ${copiedId === 'json' ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-white/5 border-white/10 text-white hover:bg-white/10'}`}
|
|
46
|
+
>
|
|
47
|
+
{copiedId === 'json' ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
48
|
+
{copiedId === 'json' ? 'Copied' : 'Copy'}
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<CodeEditor
|
|
52
|
+
value={draft}
|
|
53
|
+
onChange={(val) => {
|
|
54
|
+
setDraft(val);
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(val);
|
|
57
|
+
const normalized = normalizeTask(parsed);
|
|
58
|
+
onChange(normalized);
|
|
59
|
+
setDirty(false);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setDirty(true);
|
|
62
|
+
}
|
|
63
|
+
}}
|
|
64
|
+
language="json"
|
|
65
|
+
className="flex-1 min-h-0"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default JsonEditorPane;
|