@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,1519 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Play,
|
|
4
|
+
Copy,
|
|
5
|
+
X,
|
|
6
|
+
Check,
|
|
7
|
+
History as HistoryIcon,
|
|
8
|
+
MousePointer2,
|
|
9
|
+
Type as TypeIcon,
|
|
10
|
+
Target,
|
|
11
|
+
Keyboard,
|
|
12
|
+
Clock,
|
|
13
|
+
ArrowDownUp,
|
|
14
|
+
Code,
|
|
15
|
+
Split,
|
|
16
|
+
CornerRightDown,
|
|
17
|
+
Repeat,
|
|
18
|
+
List,
|
|
19
|
+
Variable,
|
|
20
|
+
Layers,
|
|
21
|
+
Square,
|
|
22
|
+
AlertTriangle,
|
|
23
|
+
PlayCircle,
|
|
24
|
+
Table
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
import { Task, TaskMode, ViewMode, VarType, Action, Results, ConfirmRequest } from '../types';
|
|
27
|
+
import RichInput from './RichInput';
|
|
28
|
+
import CodeEditor from './CodeEditor';
|
|
29
|
+
import { ACTION_CATALOG } from './editor/actionCatalog';
|
|
30
|
+
import ActionPalette from './editor/ActionPalette';
|
|
31
|
+
import JsonEditorPane from './editor/JsonEditorPane';
|
|
32
|
+
import ResultsPane from './editor/ResultsPane';
|
|
33
|
+
|
|
34
|
+
interface EditorScreenProps {
|
|
35
|
+
currentTask: Task;
|
|
36
|
+
setCurrentTask: (task: Task) => void;
|
|
37
|
+
tasks?: Task[];
|
|
38
|
+
editorView: ViewMode;
|
|
39
|
+
setEditorView: (view: ViewMode) => void;
|
|
40
|
+
isExecuting: boolean;
|
|
41
|
+
onSave: () => void;
|
|
42
|
+
onRun: () => void;
|
|
43
|
+
results: Results | null;
|
|
44
|
+
pinnedResults?: Results | null;
|
|
45
|
+
saveMsg: string;
|
|
46
|
+
onConfirm: (request: string | ConfirmRequest) => Promise<boolean>;
|
|
47
|
+
onNotify: (message: string, tone?: 'success' | 'error') => void;
|
|
48
|
+
onPinResults?: (results: Results) => void;
|
|
49
|
+
onUnpinResults?: () => void;
|
|
50
|
+
onRunSnapshot?: (task: Task) => void;
|
|
51
|
+
runId?: string | null;
|
|
52
|
+
onStop?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const VariableRow: React.FC<{
|
|
56
|
+
name: string;
|
|
57
|
+
def: any;
|
|
58
|
+
updateVariable: (oldName: string, name: string, type: VarType, value: any) => void;
|
|
59
|
+
removeVariable: (name: string) => void;
|
|
60
|
+
}> = ({ name, def, updateVariable, removeVariable }) => {
|
|
61
|
+
const [localName, setLocalName] = useState(name);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setLocalName(name);
|
|
65
|
+
}, [name]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex gap-2 items-center">
|
|
69
|
+
<input
|
|
70
|
+
type="text"
|
|
71
|
+
value={localName}
|
|
72
|
+
onChange={(e) => setLocalName(e.target.value)}
|
|
73
|
+
onBlur={() => {
|
|
74
|
+
if (localName !== name) updateVariable(name, localName, def.type, def.value);
|
|
75
|
+
}}
|
|
76
|
+
className="var-name flex-1 bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[10px] text-white"
|
|
77
|
+
/>
|
|
78
|
+
<select
|
|
79
|
+
value={def.type}
|
|
80
|
+
onChange={(e) => updateVariable(name, name, e.target.value as VarType, def.value)}
|
|
81
|
+
className="var-type bg-white/[0.05] border border-white/10 rounded-lg px-2 py-2 text-[8px] font-bold uppercase text-white/40"
|
|
82
|
+
>
|
|
83
|
+
<option value="string">STR</option>
|
|
84
|
+
<option value="number">NUM</option>
|
|
85
|
+
<option value="boolean">BOOL</option>
|
|
86
|
+
</select>
|
|
87
|
+
<div className="flex-1">
|
|
88
|
+
{def.type === 'boolean' ? (
|
|
89
|
+
<select
|
|
90
|
+
value={String(def.value)}
|
|
91
|
+
onChange={(e) => updateVariable(name, name, def.type, e.target.value)}
|
|
92
|
+
className="custom-select w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[10px] text-white"
|
|
93
|
+
>
|
|
94
|
+
<option value="true">True</option>
|
|
95
|
+
<option value="false">False</option>
|
|
96
|
+
</select>
|
|
97
|
+
) : (
|
|
98
|
+
<input
|
|
99
|
+
type={def.type === 'number' ? 'number' : 'text'}
|
|
100
|
+
value={def.value}
|
|
101
|
+
onChange={(e) => updateVariable(name, name, def.type, e.target.value)}
|
|
102
|
+
className="w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[10px] text-white"
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
<button onClick={() => removeVariable(name)} className="p-2 text-red-500 hover:text-red-400">×</button>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const EditorScreen: React.FC<EditorScreenProps> = ({
|
|
112
|
+
currentTask,
|
|
113
|
+
setCurrentTask,
|
|
114
|
+
tasks = [],
|
|
115
|
+
editorView,
|
|
116
|
+
setEditorView,
|
|
117
|
+
isExecuting,
|
|
118
|
+
onSave,
|
|
119
|
+
onRun,
|
|
120
|
+
results,
|
|
121
|
+
pinnedResults,
|
|
122
|
+
saveMsg,
|
|
123
|
+
onConfirm,
|
|
124
|
+
onNotify,
|
|
125
|
+
onPinResults,
|
|
126
|
+
onUnpinResults,
|
|
127
|
+
onRunSnapshot,
|
|
128
|
+
runId,
|
|
129
|
+
onStop
|
|
130
|
+
}) => {
|
|
131
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
132
|
+
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
133
|
+
const dragPointerIdRef = useRef<number | null>(null);
|
|
134
|
+
const actionsListRef = useRef<HTMLDivElement | null>(null);
|
|
135
|
+
const [, setActionClipboard] = useState<Action | null>(null);
|
|
136
|
+
const [dragState, setDragState] = useState<{
|
|
137
|
+
id: string;
|
|
138
|
+
startY: number;
|
|
139
|
+
currentY: number;
|
|
140
|
+
height: number;
|
|
141
|
+
index: number;
|
|
142
|
+
originTop: number;
|
|
143
|
+
pointerOffset: number;
|
|
144
|
+
} | null>(null);
|
|
145
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
146
|
+
const [versions, setVersions] = useState<{ id: string; timestamp: number; name: string; mode: TaskMode }[]>([]);
|
|
147
|
+
const [versionsLoading, setVersionsLoading] = useState(false);
|
|
148
|
+
const [actionPaletteOpen, setActionPaletteOpen] = useState(false);
|
|
149
|
+
const [actionPaletteQuery, setActionPaletteQuery] = useState('');
|
|
150
|
+
const [actionPaletteTargetId, setActionPaletteTargetId] = useState<string | null>(null);
|
|
151
|
+
const [versionPreview, setVersionPreview] = useState<{ id: string; timestamp: number; snapshot: Task } | null>(null);
|
|
152
|
+
const [versionPreviewLoading, setVersionPreviewLoading] = useState(false);
|
|
153
|
+
const [actionStatusById, setActionStatusById] = useState<Record<string, 'running' | 'success' | 'error' | 'skipped'>>({});
|
|
154
|
+
const getStoredSplitPercent = () => {
|
|
155
|
+
try {
|
|
156
|
+
const stored = localStorage.getItem('doppelganger.layout.leftWidthPct');
|
|
157
|
+
if (!stored) return 0.3;
|
|
158
|
+
const value = parseFloat(stored);
|
|
159
|
+
if (Number.isNaN(value)) return 0.3;
|
|
160
|
+
return Math.min(0.75, Math.max(0.25, value));
|
|
161
|
+
} catch {
|
|
162
|
+
return 0.3;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const clampEditorWidth = (value: number) => {
|
|
167
|
+
const minWidth = 320;
|
|
168
|
+
const maxWidth = Math.floor(window.innerWidth * 0.8);
|
|
169
|
+
return Math.max(minWidth, Math.min(maxWidth, value));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const [editorWidth, setEditorWidth] = useState(() => {
|
|
173
|
+
if (typeof window === 'undefined') return 360;
|
|
174
|
+
const pct = getStoredSplitPercent();
|
|
175
|
+
return clampEditorWidth(Math.round(window.innerWidth * pct));
|
|
176
|
+
});
|
|
177
|
+
const resizingRef = useRef(false);
|
|
178
|
+
const availableTasks = tasks.filter((task) => String(task.id || '') !== String(currentTask.id || ''));
|
|
179
|
+
|
|
180
|
+
const MAX_COPY_CHARS = 1000000;
|
|
181
|
+
|
|
182
|
+
const formatSize = (chars: number) => `${(chars / (1024 * 1024)).toFixed(2)} MB`;
|
|
183
|
+
|
|
184
|
+
const handleCopy = async (text: string, id: string, options?: { skipSizeConfirm?: boolean; truncatedNotice?: boolean }) => {
|
|
185
|
+
if (!text) {
|
|
186
|
+
onNotify('Nothing to copy.', 'error');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let copyText = text;
|
|
190
|
+
if (!options?.skipSizeConfirm && text.length > MAX_COPY_CHARS) {
|
|
191
|
+
const confirmed = await onConfirm({
|
|
192
|
+
message: `Copying ${formatSize(text.length)} may freeze your browser.`,
|
|
193
|
+
confirmLabel: 'Copy full',
|
|
194
|
+
cancelLabel: 'Copy segment'
|
|
195
|
+
});
|
|
196
|
+
if (!confirmed) {
|
|
197
|
+
copyText = text.slice(0, MAX_COPY_CHARS);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
203
|
+
await navigator.clipboard.writeText(copyText);
|
|
204
|
+
} else {
|
|
205
|
+
// Fallback to execCommand
|
|
206
|
+
const textArea = document.createElement("textarea");
|
|
207
|
+
textArea.value = copyText;
|
|
208
|
+
textArea.style.position = "fixed";
|
|
209
|
+
textArea.style.left = "-999999px";
|
|
210
|
+
textArea.style.top = "-999999px";
|
|
211
|
+
document.body.appendChild(textArea);
|
|
212
|
+
textArea.focus();
|
|
213
|
+
textArea.select();
|
|
214
|
+
document.execCommand('copy');
|
|
215
|
+
textArea.remove();
|
|
216
|
+
}
|
|
217
|
+
setCopied(id);
|
|
218
|
+
setTimeout(() => setCopied(null), 2000);
|
|
219
|
+
if (options?.truncatedNotice) {
|
|
220
|
+
onNotify('Copied truncated data.', 'success');
|
|
221
|
+
} else if (copyText.length !== text.length) {
|
|
222
|
+
onNotify('Copied a truncated preview.', 'success');
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error('Copy failed:', err);
|
|
226
|
+
onNotify('Copy failed.', 'error');
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const blockStartTypes = new Set(['if', 'while', 'repeat', 'foreach', 'on_error']);
|
|
231
|
+
const normalizeVarName = (raw: string) => {
|
|
232
|
+
const trimmed = (raw || '').trim();
|
|
233
|
+
const match = trimmed.match(/^\{\$([\w.]+)\}$/);
|
|
234
|
+
return match ? match[1] : trimmed;
|
|
235
|
+
};
|
|
236
|
+
const conditionOps = {
|
|
237
|
+
string: [
|
|
238
|
+
{ value: 'equals', label: 'Equals' },
|
|
239
|
+
{ value: 'not_equals', label: 'Not equal' },
|
|
240
|
+
{ value: 'contains', label: 'Contains' },
|
|
241
|
+
{ value: 'starts_with', label: 'Starts with' },
|
|
242
|
+
{ value: 'ends_with', label: 'Ends with' },
|
|
243
|
+
{ value: 'matches', label: 'Matches regex' }
|
|
244
|
+
],
|
|
245
|
+
number: [
|
|
246
|
+
{ value: 'equals', label: 'Equals' },
|
|
247
|
+
{ value: 'not_equals', label: 'Not equal' },
|
|
248
|
+
{ value: 'gt', label: 'Greater than' },
|
|
249
|
+
{ value: 'gte', label: 'Greater or equal' },
|
|
250
|
+
{ value: 'lt', label: 'Less than' },
|
|
251
|
+
{ value: 'lte', label: 'Less or equal' }
|
|
252
|
+
],
|
|
253
|
+
boolean: [
|
|
254
|
+
{ value: 'is_true', label: 'Is true' },
|
|
255
|
+
{ value: 'is_false', label: 'Is false' }
|
|
256
|
+
]
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const getBlockDepths = (actions: Action[]) => {
|
|
260
|
+
let depth = 0;
|
|
261
|
+
return actions.map((action) => {
|
|
262
|
+
if (action.type === 'else' || action.type === 'end') {
|
|
263
|
+
depth = Math.max(0, depth - 1);
|
|
264
|
+
}
|
|
265
|
+
const currentDepth = depth;
|
|
266
|
+
if (action.type === 'else' || blockStartTypes.has(action.type)) {
|
|
267
|
+
depth += 1;
|
|
268
|
+
}
|
|
269
|
+
return currentDepth;
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const addActionByType = (type: Action['type']) => {
|
|
274
|
+
const base: Action = {
|
|
275
|
+
id: "act_" + Date.now(),
|
|
276
|
+
type,
|
|
277
|
+
selector: '',
|
|
278
|
+
value: ''
|
|
279
|
+
};
|
|
280
|
+
if (type === 'set') base.varName = '';
|
|
281
|
+
if (type === 'merge') base.varName = '';
|
|
282
|
+
if (type === 'start') base.value = '';
|
|
283
|
+
if (type === 'if') {
|
|
284
|
+
base.conditionVar = '';
|
|
285
|
+
base.conditionVarType = 'string';
|
|
286
|
+
base.conditionOp = 'equals';
|
|
287
|
+
base.conditionValue = '';
|
|
288
|
+
}
|
|
289
|
+
setCurrentTask({ ...currentTask, actions: [...currentTask.actions, base] });
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const openActionPalette = (targetId?: string) => {
|
|
293
|
+
setActionPaletteOpen(true);
|
|
294
|
+
setActionPaletteQuery('');
|
|
295
|
+
setActionPaletteTargetId(targetId || null);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const removeAction = (id: string) => {
|
|
299
|
+
setCurrentTask({ ...currentTask, actions: currentTask.actions.filter(a => a.id !== id) });
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const updateAction = (id: string, updates: Partial<Action>) => {
|
|
303
|
+
setCurrentTask({ ...currentTask, actions: currentTask.actions.map(a => a.id === id ? { ...a, ...updates } : a) });
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const moveAction = (fromId: string, toId: string) => {
|
|
307
|
+
if (fromId === toId) return;
|
|
308
|
+
const actions = [...currentTask.actions];
|
|
309
|
+
const fromIndex = actions.findIndex((a) => a.id === fromId);
|
|
310
|
+
const toIndex = actions.findIndex((a) => a.id === toId);
|
|
311
|
+
if (fromIndex === -1 || toIndex === -1) return;
|
|
312
|
+
const [moved] = actions.splice(fromIndex, 1);
|
|
313
|
+
actions.splice(toIndex, 0, moved);
|
|
314
|
+
setCurrentTask({ ...currentTask, actions });
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const isInteractiveTarget = (target: EventTarget | null) => {
|
|
318
|
+
if (!target || !(target instanceof HTMLElement)) return false;
|
|
319
|
+
return !!target.closest('input, textarea, select, button, a, [contenteditable="true"], [data-no-drag="true"]');
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const getDragIndexFromY = (pointerY: number, activeId: string, snapIndex?: number, snapCenter?: number) => {
|
|
323
|
+
if (snapIndex !== undefined && snapCenter !== undefined) {
|
|
324
|
+
if (Math.abs(pointerY - snapCenter) < 14) {
|
|
325
|
+
return snapIndex;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const actions = currentTask.actions;
|
|
329
|
+
let nextIndex = actions.length - 1;
|
|
330
|
+
for (let i = 0; i < actions.length; i++) {
|
|
331
|
+
if (actions[i].id === activeId) continue;
|
|
332
|
+
const el = document.getElementById(`action-${actions[i].id}`);
|
|
333
|
+
if (!el) continue;
|
|
334
|
+
const rect = el.getBoundingClientRect();
|
|
335
|
+
const midpoint = rect.top + rect.height * 0.4;
|
|
336
|
+
if (pointerY < midpoint) {
|
|
337
|
+
nextIndex = i;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return nextIndex;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const finalizeDrag = () => {
|
|
345
|
+
if (!dragState) return;
|
|
346
|
+
if (dragOverIndex !== null && dragOverIndex !== dragState.index) {
|
|
347
|
+
const targetId = currentTask.actions[dragOverIndex]?.id;
|
|
348
|
+
if (targetId) moveAction(dragState.id, targetId);
|
|
349
|
+
}
|
|
350
|
+
setDragState(null);
|
|
351
|
+
setDragOverIndex(null);
|
|
352
|
+
dragPointerIdRef.current = null;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (!dragState) return;
|
|
357
|
+
|
|
358
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
359
|
+
if (dragPointerIdRef.current !== null && e.pointerId !== dragPointerIdRef.current) return;
|
|
360
|
+
if (actionsListRef.current) {
|
|
361
|
+
const rect = actionsListRef.current.getBoundingClientRect();
|
|
362
|
+
if (e.clientY < rect.top + 28) actionsListRef.current.scrollTop -= 14;
|
|
363
|
+
if (e.clientY > rect.bottom - 28) actionsListRef.current.scrollTop += 14;
|
|
364
|
+
}
|
|
365
|
+
const originCenter = dragState.originTop + dragState.height / 2;
|
|
366
|
+
const nextIndex = getDragIndexFromY(e.clientY, dragState.id, dragState.index, originCenter);
|
|
367
|
+
setDragState((prev) => prev ? { ...prev, currentY: e.clientY } : prev);
|
|
368
|
+
setDragOverIndex(nextIndex);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const handlePointerUp = (e: PointerEvent) => {
|
|
372
|
+
if (dragPointerIdRef.current !== null && e.pointerId !== dragPointerIdRef.current) return;
|
|
373
|
+
finalizeDrag();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
377
|
+
window.addEventListener('pointerup', handlePointerUp);
|
|
378
|
+
window.addEventListener('pointercancel', handlePointerUp);
|
|
379
|
+
return () => {
|
|
380
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
381
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
382
|
+
window.removeEventListener('pointercancel', handlePointerUp);
|
|
383
|
+
};
|
|
384
|
+
}, [dragState, dragOverIndex, currentTask.actions]);
|
|
385
|
+
|
|
386
|
+
const addVariable = () => {
|
|
387
|
+
const name = "var_" + Date.now().toString().slice(-4);
|
|
388
|
+
setCurrentTask({ ...currentTask, variables: { ...currentTask.variables, [name]: { type: 'string', value: '' } } });
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const createActionClone = (action: Action) => ({
|
|
392
|
+
...action,
|
|
393
|
+
id: "act_" + Date.now() + "_" + Math.floor(Math.random() * 1000)
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const openContextMenu = (e: React.MouseEvent, id: string) => {
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
const padding = 8;
|
|
399
|
+
const width = 200;
|
|
400
|
+
const height = 190;
|
|
401
|
+
const x = Math.min(Math.max(e.clientX + 12, padding), window.innerWidth - width - padding);
|
|
402
|
+
const y = Math.min(Math.max(e.clientY + 12, padding), window.innerHeight - height - padding);
|
|
403
|
+
setContextMenu({ id, x, y });
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const closeContextMenu = () => setContextMenu(null);
|
|
407
|
+
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!contextMenu) return;
|
|
410
|
+
const handleClick = (e: Event) => {
|
|
411
|
+
if ((e.target as HTMLElement)?.closest('.action-context-menu')) return;
|
|
412
|
+
setContextMenu(null);
|
|
413
|
+
};
|
|
414
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
415
|
+
if (e.key === 'Escape') setContextMenu(null);
|
|
416
|
+
};
|
|
417
|
+
window.addEventListener('mousedown', handleClick);
|
|
418
|
+
window.addEventListener('keydown', handleKey);
|
|
419
|
+
window.addEventListener('scroll', handleClick, true);
|
|
420
|
+
return () => {
|
|
421
|
+
window.removeEventListener('mousedown', handleClick);
|
|
422
|
+
window.removeEventListener('keydown', handleKey);
|
|
423
|
+
window.removeEventListener('scroll', handleClick, true);
|
|
424
|
+
};
|
|
425
|
+
}, [contextMenu]);
|
|
426
|
+
|
|
427
|
+
useEffect(() => {
|
|
428
|
+
if (!runId || currentTask.mode !== 'agent') return;
|
|
429
|
+
setActionStatusById({});
|
|
430
|
+
const source = new EventSource(`/api/executions/stream?runId=${encodeURIComponent(runId)}`, { withCredentials: true });
|
|
431
|
+
source.onmessage = (event) => {
|
|
432
|
+
if (!event.data) return;
|
|
433
|
+
try {
|
|
434
|
+
const payload = JSON.parse(event.data);
|
|
435
|
+
if (payload && payload.actionId && payload.status) {
|
|
436
|
+
setActionStatusById((prev) => ({ ...prev, [payload.actionId]: payload.status }));
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// ignore
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
source.addEventListener('ready', () => {
|
|
443
|
+
// stream is alive
|
|
444
|
+
});
|
|
445
|
+
source.onopen = () => {
|
|
446
|
+
// connected
|
|
447
|
+
};
|
|
448
|
+
source.onerror = () => {
|
|
449
|
+
// avoid keeping a dead connection open
|
|
450
|
+
source.close();
|
|
451
|
+
};
|
|
452
|
+
return () => {
|
|
453
|
+
source.close();
|
|
454
|
+
};
|
|
455
|
+
}, [runId, currentTask.mode]);
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (typeof window === 'undefined') return;
|
|
459
|
+
const pct = getStoredSplitPercent();
|
|
460
|
+
setEditorWidth(clampEditorWidth(Math.round(window.innerWidth * pct)));
|
|
461
|
+
}, []);
|
|
462
|
+
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
465
|
+
if (!resizingRef.current) return;
|
|
466
|
+
const next = clampEditorWidth(event.clientX);
|
|
467
|
+
setEditorWidth(next);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const handlePointerUp = () => {
|
|
471
|
+
resizingRef.current = false;
|
|
472
|
+
document.body.style.cursor = '';
|
|
473
|
+
document.body.style.userSelect = '';
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
477
|
+
window.addEventListener('pointerup', handlePointerUp);
|
|
478
|
+
window.addEventListener('pointercancel', handlePointerUp);
|
|
479
|
+
return () => {
|
|
480
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
481
|
+
window.removeEventListener('pointerup', handlePointerUp);
|
|
482
|
+
window.removeEventListener('pointercancel', handlePointerUp);
|
|
483
|
+
};
|
|
484
|
+
}, []);
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
const removeVariable = (name: string) => {
|
|
488
|
+
const next = { ...currentTask.variables };
|
|
489
|
+
delete next[name];
|
|
490
|
+
setCurrentTask({ ...currentTask, variables: next });
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const updateVariable = (oldName: string, name: string, type: VarType, value: any) => {
|
|
494
|
+
const next = { ...currentTask.variables };
|
|
495
|
+
delete next[oldName];
|
|
496
|
+
let processedValue = value;
|
|
497
|
+
if (type === 'number') processedValue = parseFloat(value) || 0;
|
|
498
|
+
if (type === 'boolean') processedValue = value === 'true' || value === true;
|
|
499
|
+
next[name] = { type, value: processedValue };
|
|
500
|
+
setCurrentTask({ ...currentTask, variables: next });
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const loadVersions = async () => {
|
|
504
|
+
if (!currentTask.id) return;
|
|
505
|
+
setVersionsLoading(true);
|
|
506
|
+
try {
|
|
507
|
+
const res = await fetch(`/api/tasks/${currentTask.id}/versions`);
|
|
508
|
+
if (!res.ok) throw new Error('Failed to load versions');
|
|
509
|
+
const data = await res.json();
|
|
510
|
+
setVersions(Array.isArray(data.versions) ? data.versions : []);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
setVersions([]);
|
|
513
|
+
} finally {
|
|
514
|
+
setVersionsLoading(false);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const rollbackToVersion = async (versionId: string) => {
|
|
519
|
+
if (!currentTask.id) return;
|
|
520
|
+
const confirmed = await onConfirm('Rollback to this version? Current changes will be saved as a new version.');
|
|
521
|
+
if (!confirmed) return;
|
|
522
|
+
try {
|
|
523
|
+
const res = await fetch(`/api/tasks/${currentTask.id}/rollback`, {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify({ versionId })
|
|
527
|
+
});
|
|
528
|
+
if (!res.ok) throw new Error('Rollback failed');
|
|
529
|
+
const restored = await res.json();
|
|
530
|
+
setCurrentTask(restored);
|
|
531
|
+
onNotify('Rolled back to selected version.', 'success');
|
|
532
|
+
loadVersions();
|
|
533
|
+
} catch (e) {
|
|
534
|
+
onNotify('Rollback failed.', 'error');
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const openVersionPreview = async (versionId: string) => {
|
|
539
|
+
if (!currentTask.id) return;
|
|
540
|
+
setVersionPreviewLoading(true);
|
|
541
|
+
try {
|
|
542
|
+
const res = await fetch(`/api/tasks/${currentTask.id}/versions/${versionId}`);
|
|
543
|
+
if (!res.ok) throw new Error('Failed to load version');
|
|
544
|
+
const data = await res.json();
|
|
545
|
+
if (!data?.snapshot) throw new Error('Missing snapshot');
|
|
546
|
+
setVersionPreview({
|
|
547
|
+
id: data.metadata?.id || versionId,
|
|
548
|
+
timestamp: data.metadata?.timestamp || Date.now(),
|
|
549
|
+
snapshot: data.snapshot
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
onNotify('Failed to load version snapshot.', 'error');
|
|
553
|
+
} finally {
|
|
554
|
+
setVersionPreviewLoading(false);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
useEffect(() => {
|
|
559
|
+
if (editorView === 'history') loadVersions();
|
|
560
|
+
}, [editorView, currentTask.id]);
|
|
561
|
+
|
|
562
|
+
return (
|
|
563
|
+
<div className="flex-1 flex overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
564
|
+
<aside className="glass border-r border-white/10 flex flex-col shrink-0 overflow-hidden" style={{ width: editorWidth }}>
|
|
565
|
+
<div className="p-8 border-b border-white/10 space-y-6 shrink-0">
|
|
566
|
+
<div className="flex items-center justify-between">
|
|
567
|
+
<input
|
|
568
|
+
type="text"
|
|
569
|
+
value={currentTask.name}
|
|
570
|
+
onChange={(e) => setCurrentTask({ ...currentTask, name: e.target.value })}
|
|
571
|
+
placeholder="Task Name..."
|
|
572
|
+
className="bg-transparent text-xl font-bold tracking-tight text-white focus:outline-none border-none p-0 w-full placeholder:text-white/10"
|
|
573
|
+
/>
|
|
574
|
+
<div className="flex items-center gap-6">
|
|
575
|
+
<button
|
|
576
|
+
onClick={() => setEditorView('history')}
|
|
577
|
+
className="w-8 h-8 rounded-full text-gray-400 hover:text-white hover:bg-white/5 transition-all flex items-center justify-center"
|
|
578
|
+
title="Task History"
|
|
579
|
+
>
|
|
580
|
+
<HistoryIcon className="w-3.5 h-3.5" />
|
|
581
|
+
</button>
|
|
582
|
+
<button
|
|
583
|
+
onClick={onSave}
|
|
584
|
+
className={`px-4 py-2 text-[9px] font-bold rounded-full uppercase tracking-widest transition-all ${saveMsg === 'SAVED' ? 'text-green-400 border border-green-400/20' : 'bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20'}`}
|
|
585
|
+
>
|
|
586
|
+
{saveMsg === 'SAVED' ? 'SAVED' : 'SAVE'}
|
|
587
|
+
</button>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
|
|
591
|
+
<div className="bg-white/5 p-1 rounded-xl flex gap-1 border border-white/5">
|
|
592
|
+
{(['scrape', 'agent', 'headful'] as TaskMode[]).map(m => (
|
|
593
|
+
<button
|
|
594
|
+
key={m}
|
|
595
|
+
onClick={() => setCurrentTask({ ...currentTask, mode: m })}
|
|
596
|
+
className={`flex-1 py-2 text-[9px] font-bold uppercase tracking-widest rounded-lg transition-all ${currentTask.mode === m ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
597
|
+
>
|
|
598
|
+
{m === 'scrape' ? 'Scraper' : m === 'agent' ? 'Agent' : 'Headful'}
|
|
599
|
+
</button>
|
|
600
|
+
))}
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
<div className="flex items-center justify-between px-2">
|
|
604
|
+
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest">Interface Mode</span>
|
|
605
|
+
<div className="flex bg-white/5 rounded-lg p-0.5 border border-white/5">
|
|
606
|
+
{(['visual', 'json', 'api'] as ViewMode[]).map(v => (
|
|
607
|
+
<button
|
|
608
|
+
key={v}
|
|
609
|
+
onClick={() => setEditorView(v)}
|
|
610
|
+
className={`px-3 py-1 rounded text-[8px] font-bold uppercase tracking-widest transition-all ${editorView === v ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
611
|
+
>
|
|
612
|
+
{v}
|
|
613
|
+
</button>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
<div
|
|
620
|
+
className={`flex-1 p-8 min-h-0 relative ${editorView === 'json' ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar'} ${editorView === 'visual' ? 'space-y-8' : ''}`}
|
|
621
|
+
>
|
|
622
|
+
{editorView === 'visual' && (
|
|
623
|
+
<div className="space-y-6">
|
|
624
|
+
<div className="space-y-2">
|
|
625
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">Target URL</label>
|
|
626
|
+
<div className="w-full bg-white/[0.05] border border-white/10 rounded-xl px-4 py-3 text-sm focus-within:border-white/30 transition-all">
|
|
627
|
+
<RichInput
|
|
628
|
+
value={currentTask.url}
|
|
629
|
+
onChange={(val) => setCurrentTask({ ...currentTask, url: val })}
|
|
630
|
+
variables={currentTask.variables}
|
|
631
|
+
placeholder="https://..."
|
|
632
|
+
/>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<div className="grid grid-cols-2 gap-4">
|
|
637
|
+
<div className="space-y-2">
|
|
638
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">Wait (Sec)</label>
|
|
639
|
+
<input
|
|
640
|
+
type="number"
|
|
641
|
+
value={currentTask.wait}
|
|
642
|
+
onChange={(e) => setCurrentTask({ ...currentTask, wait: parseFloat(e.target.value) || 0 })}
|
|
643
|
+
className="w-full bg-white/[0.05] border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-white/30 transition-all text-white"
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
{currentTask.mode === 'agent' && (
|
|
649
|
+
<div className="space-y-6 order-1">
|
|
650
|
+
<div className="space-y-3" ref={actionsListRef}>
|
|
651
|
+
{(() => {
|
|
652
|
+
const blockDepths = getBlockDepths(currentTask.actions);
|
|
653
|
+
return currentTask.actions.map((action, idx) => {
|
|
654
|
+
const isDragging = dragState?.id === action.id;
|
|
655
|
+
const isBetween =
|
|
656
|
+
dragState &&
|
|
657
|
+
dragOverIndex !== null &&
|
|
658
|
+
dragState.index !== dragOverIndex &&
|
|
659
|
+
action.id !== dragState.id &&
|
|
660
|
+
((dragState.index < dragOverIndex && idx > dragState.index && idx <= dragOverIndex) ||
|
|
661
|
+
(dragState.index > dragOverIndex && idx < dragState.index && idx >= dragOverIndex));
|
|
662
|
+
const translateY = isBetween ? (dragState?.height || 0) * (dragState.index < (dragOverIndex ?? 0) ? -1 : 1) : 0;
|
|
663
|
+
const depth = blockDepths[idx] || 0;
|
|
664
|
+
const status = action.disabled ? 'skipped' : actionStatusById[action.id];
|
|
665
|
+
const statusClass = status === 'running'
|
|
666
|
+
? 'border-yellow-400/60'
|
|
667
|
+
: status === 'success'
|
|
668
|
+
? 'border-green-400/60'
|
|
669
|
+
: status === 'error'
|
|
670
|
+
? 'border-red-400/70'
|
|
671
|
+
: status === 'skipped'
|
|
672
|
+
? 'border-gray-500/40'
|
|
673
|
+
: '';
|
|
674
|
+
const renderBlockMarker = (type: Action['type']) => {
|
|
675
|
+
const iconClass = "w-3 h-3";
|
|
676
|
+
if (type === 'if' || type === 'else') return <Split className={`${iconClass} text-blue-400`} />;
|
|
677
|
+
if (type === 'end') return <CornerRightDown className={`${iconClass} text-gray-500`} />;
|
|
678
|
+
if (type === 'while' || type === 'repeat') return <Repeat className={`${iconClass} text-amber-400`} />;
|
|
679
|
+
if (type === 'foreach') return <List className={`${iconClass} text-amber-300`} />;
|
|
680
|
+
if (type === 'on_error') return <AlertTriangle className={`${iconClass} text-red-400`} />;
|
|
681
|
+
if (type === 'set') return <Variable className={`${iconClass} text-green-400`} />;
|
|
682
|
+
if (type === 'stop') return <Square className={`${iconClass} text-red-400`} />;
|
|
683
|
+
if (type === 'click') return <MousePointer2 className={`${iconClass} text-blue-300`} />;
|
|
684
|
+
if (type === 'type') return <TypeIcon className={`${iconClass} text-green-300`} />;
|
|
685
|
+
if (type === 'hover') return <Target className={`${iconClass} text-purple-300`} />;
|
|
686
|
+
if (type === 'press') return <Keyboard className={`${iconClass} text-amber-300`} />;
|
|
687
|
+
if (type === 'wait') return <Clock className={`${iconClass} text-slate-300`} />;
|
|
688
|
+
if (type === 'scroll') return <ArrowDownUp className={`${iconClass} text-cyan-300`} />;
|
|
689
|
+
if (type === 'javascript') return <Code className={`${iconClass} text-yellow-300`} />;
|
|
690
|
+
if (type === 'csv') return <Table className={`${iconClass} text-emerald-300`} />;
|
|
691
|
+
if (type === 'merge') return <Layers className={`${iconClass} text-emerald-200`} />;
|
|
692
|
+
if (type === 'start') return <PlayCircle className={`${iconClass} text-emerald-300`} />;
|
|
693
|
+
return <span className="text-[9px] text-white/20">|</span>;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
return (
|
|
697
|
+
<div
|
|
698
|
+
key={action.id}
|
|
699
|
+
id={`action-${action.id}`}
|
|
700
|
+
onPointerDown={(e) => {
|
|
701
|
+
if (isInteractiveTarget(e.target)) return;
|
|
702
|
+
if (e.button !== 0) return;
|
|
703
|
+
e.preventDefault();
|
|
704
|
+
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
705
|
+
const pointerOffset = e.clientY - rect.top;
|
|
706
|
+
dragPointerIdRef.current = e.pointerId;
|
|
707
|
+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
|
708
|
+
setDragState({
|
|
709
|
+
id: action.id,
|
|
710
|
+
startY: e.clientY,
|
|
711
|
+
currentY: e.clientY,
|
|
712
|
+
height: rect.height,
|
|
713
|
+
index: idx,
|
|
714
|
+
originTop: rect.top,
|
|
715
|
+
pointerOffset
|
|
716
|
+
});
|
|
717
|
+
setDragOverIndex(idx);
|
|
718
|
+
}}
|
|
719
|
+
onPointerUp={(e) => {
|
|
720
|
+
if (dragPointerIdRef.current !== null && e.pointerId !== dragPointerIdRef.current) return;
|
|
721
|
+
finalizeDrag();
|
|
722
|
+
}}
|
|
723
|
+
onContextMenu={(e) => openContextMenu(e, action.id)}
|
|
724
|
+
className={`glass-card p-5 rounded-2xl space-y-4 group/item relative transition-[transform,box-shadow,opacity,filter,background-color,border-color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform select-none touch-none ${statusClass} ${isDragging ? 'ring-2 ring-white/40 scale-[1.02] -translate-y-0.5 shadow-[0_30px_80px_rgba(0,0,0,0.45)] opacity-85 z-20' : ''} ${dragOverIndex === idx && !isDragging ? 'ring-2 ring-blue-400/60 bg-blue-500/5' : ''} ${action.disabled ? 'opacity-40 grayscale' : ''}`}
|
|
725
|
+
style={{
|
|
726
|
+
transform: isDragging
|
|
727
|
+
? `translateY(${(dragState?.currentY || 0) - (dragState?.pointerOffset || 0) - (dragState?.originTop || 0)}px)`
|
|
728
|
+
: translateY
|
|
729
|
+
? `translateY(${translateY}px)`
|
|
730
|
+
: undefined,
|
|
731
|
+
marginLeft: depth ? depth * 12 : undefined
|
|
732
|
+
}}
|
|
733
|
+
>
|
|
734
|
+
<div className="flex items-center justify-between">
|
|
735
|
+
<div className="flex items-center gap-3">
|
|
736
|
+
<div className="text-[8px] font-bold text-white/20 font-mono tracking-tighter">{(idx + 1).toString().padStart(2, '0')}</div>
|
|
737
|
+
<div className="w-4 h-4 flex items-center justify-center">
|
|
738
|
+
{renderBlockMarker(action.type)}
|
|
739
|
+
</div>
|
|
740
|
+
<button
|
|
741
|
+
onClick={() => openActionPalette(action.id)}
|
|
742
|
+
className="action-type-select text-[10px] font-bold uppercase tracking-[0.2em] text-blue-400 focus:outline-none cursor-pointer"
|
|
743
|
+
>
|
|
744
|
+
{ACTION_CATALOG.find((item) => item.type === action.type)?.label || action.type}
|
|
745
|
+
</button>
|
|
746
|
+
</div>
|
|
747
|
+
<button
|
|
748
|
+
data-no-drag="true"
|
|
749
|
+
onClick={() => removeAction(action.id)}
|
|
750
|
+
className="text-gray-600 hover:text-red-500 transition-colors opacity-0 group-hover/item:opacity-100"
|
|
751
|
+
>
|
|
752
|
+
<X className="w-4 h-4" />
|
|
753
|
+
</button>
|
|
754
|
+
</div>
|
|
755
|
+
{(action.type === 'click' || action.type === 'type' || action.type === 'hover') && (
|
|
756
|
+
<div className="space-y-1.5">
|
|
757
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Selector</label>
|
|
758
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
759
|
+
<RichInput
|
|
760
|
+
value={action.selector || ''}
|
|
761
|
+
onChange={(v) => updateAction(action.id, { selector: v })}
|
|
762
|
+
variables={currentTask.variables}
|
|
763
|
+
placeholder=".btn-primary"
|
|
764
|
+
/>
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
)}
|
|
768
|
+
|
|
769
|
+
{action.type === 'scroll' && (
|
|
770
|
+
<div className="space-y-1.5">
|
|
771
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Selector (Optional)</label>
|
|
772
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
773
|
+
<RichInput
|
|
774
|
+
value={action.selector || ''}
|
|
775
|
+
onChange={(v) => updateAction(action.id, { selector: v })}
|
|
776
|
+
variables={currentTask.variables}
|
|
777
|
+
placeholder=".scroll-container or leave empty"
|
|
778
|
+
/>
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
|
|
783
|
+
{(action.type === 'type' || action.type === 'wait' || action.type === 'scroll' || action.type === 'javascript' || action.type === 'csv') && (
|
|
784
|
+
<div className="space-y-1.5">
|
|
785
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">
|
|
786
|
+
{action.type === 'type'
|
|
787
|
+
? 'Content'
|
|
788
|
+
: action.type === 'wait'
|
|
789
|
+
? 'Seconds'
|
|
790
|
+
: action.type === 'scroll'
|
|
791
|
+
? 'Pixels'
|
|
792
|
+
: action.type === 'csv'
|
|
793
|
+
? 'CSV Input'
|
|
794
|
+
: 'Script'}
|
|
795
|
+
</label>
|
|
796
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
797
|
+
{action.type === 'javascript' ? (
|
|
798
|
+
<CodeEditor
|
|
799
|
+
value={action.value || ''}
|
|
800
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
801
|
+
language="javascript"
|
|
802
|
+
variables={currentTask.variables}
|
|
803
|
+
className="min-h-[120px]"
|
|
804
|
+
placeholder="return document.title"
|
|
805
|
+
/>
|
|
806
|
+
) : action.type === 'csv' ? (
|
|
807
|
+
<CodeEditor
|
|
808
|
+
value={action.value || ''}
|
|
809
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
810
|
+
language="plain"
|
|
811
|
+
variables={currentTask.variables}
|
|
812
|
+
className="min-h-[120px]"
|
|
813
|
+
placeholder="name,age\nAda,31"
|
|
814
|
+
/>
|
|
815
|
+
) : (
|
|
816
|
+
<RichInput
|
|
817
|
+
value={action.value || ''}
|
|
818
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
819
|
+
variables={currentTask.variables}
|
|
820
|
+
placeholder={action.type === 'type' ? 'Search keywords' : action.type === 'wait' ? '3' : '400'}
|
|
821
|
+
/>
|
|
822
|
+
)}
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
)}
|
|
826
|
+
|
|
827
|
+
{action.type === 'press' && (
|
|
828
|
+
<div className="space-y-1.5">
|
|
829
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Key</label>
|
|
830
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
831
|
+
<RichInput
|
|
832
|
+
value={action.key || ''}
|
|
833
|
+
onChange={(v) => updateAction(action.id, { key: v })}
|
|
834
|
+
variables={currentTask.variables}
|
|
835
|
+
placeholder="Enter"
|
|
836
|
+
/>
|
|
837
|
+
</div>
|
|
838
|
+
</div>
|
|
839
|
+
)}
|
|
840
|
+
|
|
841
|
+
{action.type === 'if' && (() => {
|
|
842
|
+
const varKeys = Object.keys(currentTask.variables || {});
|
|
843
|
+
const normalizedVar = normalizeVarName(action.conditionVar || '');
|
|
844
|
+
const inferredType = normalizedVar && currentTask.variables?.[normalizedVar]?.type;
|
|
845
|
+
const varType = action.conditionVarType || inferredType || 'string';
|
|
846
|
+
const ops = conditionOps[varType as VarType] || conditionOps.string;
|
|
847
|
+
const opValue = action.conditionOp || ops[0].value;
|
|
848
|
+
return (
|
|
849
|
+
<div className="space-y-2">
|
|
850
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Condition</label>
|
|
851
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
852
|
+
<div className="space-y-1">
|
|
853
|
+
<span className="text-[7px] font-bold text-gray-500 uppercase tracking-widest pl-1">Variable</span>
|
|
854
|
+
<input
|
|
855
|
+
type="text"
|
|
856
|
+
list={`if-var-${action.id}`}
|
|
857
|
+
value={action.conditionVar || ''}
|
|
858
|
+
onChange={(e) => updateAction(action.id, { conditionVar: e.target.value })}
|
|
859
|
+
placeholder="variable name"
|
|
860
|
+
className="w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[10px] text-white"
|
|
861
|
+
/>
|
|
862
|
+
{varKeys.length > 0 && (
|
|
863
|
+
<datalist id={`if-var-${action.id}`}>
|
|
864
|
+
{varKeys.map((key) => (
|
|
865
|
+
<option key={key} value={key} />
|
|
866
|
+
))}
|
|
867
|
+
</datalist>
|
|
868
|
+
)}
|
|
869
|
+
</div>
|
|
870
|
+
<div className="space-y-1">
|
|
871
|
+
<span className="text-[7px] font-bold text-gray-500 uppercase tracking-widest pl-1">Type</span>
|
|
872
|
+
<select
|
|
873
|
+
value={varType}
|
|
874
|
+
onChange={(e) => {
|
|
875
|
+
const nextType = e.target.value as VarType;
|
|
876
|
+
const nextOps = conditionOps[nextType] || conditionOps.string;
|
|
877
|
+
updateAction(action.id, {
|
|
878
|
+
conditionVarType: nextType,
|
|
879
|
+
conditionOp: nextOps[0].value,
|
|
880
|
+
conditionValue: nextType === 'boolean' ? '' : action.conditionValue || ''
|
|
881
|
+
});
|
|
882
|
+
}}
|
|
883
|
+
className="custom-select w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[8px] font-bold uppercase text-white/60"
|
|
884
|
+
>
|
|
885
|
+
<option value="string">String</option>
|
|
886
|
+
<option value="number">Number</option>
|
|
887
|
+
<option value="boolean">Boolean</option>
|
|
888
|
+
</select>
|
|
889
|
+
</div>
|
|
890
|
+
<div className="space-y-1">
|
|
891
|
+
<span className="text-[7px] font-bold text-gray-500 uppercase tracking-widest pl-1">Relation</span>
|
|
892
|
+
<select
|
|
893
|
+
value={opValue}
|
|
894
|
+
onChange={(e) => updateAction(action.id, { conditionOp: e.target.value })}
|
|
895
|
+
className="custom-select w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[8px] font-bold uppercase text-white/60"
|
|
896
|
+
>
|
|
897
|
+
{ops.map((opt) => (
|
|
898
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
899
|
+
))}
|
|
900
|
+
</select>
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
{varType !== 'boolean' && (
|
|
904
|
+
<div className="space-y-1">
|
|
905
|
+
<span className="text-[7px] font-bold text-gray-500 uppercase tracking-widest pl-1">Value</span>
|
|
906
|
+
<input
|
|
907
|
+
type={varType === 'number' ? 'number' : 'text'}
|
|
908
|
+
value={action.conditionValue || ''}
|
|
909
|
+
onChange={(e) => updateAction(action.id, { conditionValue: e.target.value })}
|
|
910
|
+
placeholder={varType === 'number' ? '0' : 'value'}
|
|
911
|
+
className="w-full bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[10px] text-white"
|
|
912
|
+
/>
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
915
|
+
</div>
|
|
916
|
+
);
|
|
917
|
+
})()}
|
|
918
|
+
|
|
919
|
+
{action.type === 'while' && (
|
|
920
|
+
<div className="space-y-1.5">
|
|
921
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Condition (JS)</label>
|
|
922
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
923
|
+
<RichInput
|
|
924
|
+
value={action.value || ''}
|
|
925
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
926
|
+
variables={currentTask.variables}
|
|
927
|
+
placeholder="exists('.login') && text('h1').includes('Welcome')"
|
|
928
|
+
/>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
)}
|
|
932
|
+
|
|
933
|
+
{action.type === 'repeat' && (
|
|
934
|
+
<div className="space-y-1.5">
|
|
935
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Times</label>
|
|
936
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
937
|
+
<RichInput
|
|
938
|
+
value={action.value || ''}
|
|
939
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
940
|
+
variables={currentTask.variables}
|
|
941
|
+
placeholder="3"
|
|
942
|
+
/>
|
|
943
|
+
</div>
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
|
|
947
|
+
{action.type === 'foreach' && (
|
|
948
|
+
<div className="space-y-3">
|
|
949
|
+
<div className="space-y-1.5">
|
|
950
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Selector (Optional)</label>
|
|
951
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
952
|
+
<RichInput
|
|
953
|
+
value={action.selector || ''}
|
|
954
|
+
onChange={(v) => updateAction(action.id, { selector: v })}
|
|
955
|
+
variables={currentTask.variables}
|
|
956
|
+
placeholder=".list-item"
|
|
957
|
+
/>
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
<div className="space-y-1.5">
|
|
961
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Variable (Array Name)</label>
|
|
962
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
963
|
+
<RichInput
|
|
964
|
+
value={action.varName || ''}
|
|
965
|
+
onChange={(v) => updateAction(action.id, { varName: v })}
|
|
966
|
+
variables={currentTask.variables}
|
|
967
|
+
placeholder="items"
|
|
968
|
+
/>
|
|
969
|
+
</div>
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
)}
|
|
973
|
+
|
|
974
|
+
{action.type === 'set' && (
|
|
975
|
+
<div className="space-y-3">
|
|
976
|
+
<div className="space-y-1.5">
|
|
977
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Variable Name</label>
|
|
978
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
979
|
+
<RichInput
|
|
980
|
+
value={action.varName || ''}
|
|
981
|
+
onChange={(v) => updateAction(action.id, { varName: v })}
|
|
982
|
+
variables={currentTask.variables}
|
|
983
|
+
placeholder="status"
|
|
984
|
+
/>
|
|
985
|
+
</div>
|
|
986
|
+
</div>
|
|
987
|
+
<div className="space-y-1.5">
|
|
988
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Value</label>
|
|
989
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
990
|
+
<RichInput
|
|
991
|
+
value={action.value || ''}
|
|
992
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
993
|
+
variables={currentTask.variables}
|
|
994
|
+
placeholder="ready"
|
|
995
|
+
/>
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
)}
|
|
1000
|
+
|
|
1001
|
+
{action.type === 'merge' && (
|
|
1002
|
+
<div className="space-y-3">
|
|
1003
|
+
<div className="space-y-1.5">
|
|
1004
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Sources</label>
|
|
1005
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
1006
|
+
<RichInput
|
|
1007
|
+
value={action.value || ''}
|
|
1008
|
+
onChange={(v) => updateAction(action.id, { value: v })}
|
|
1009
|
+
variables={currentTask.variables}
|
|
1010
|
+
placeholder="items, extraItems, {$block.output}"
|
|
1011
|
+
/>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
<div className="space-y-1.5">
|
|
1015
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Target Variable (Optional)</label>
|
|
1016
|
+
<div className="bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[11px] focus-within:border-white/20 transition-all">
|
|
1017
|
+
<RichInput
|
|
1018
|
+
value={action.varName || ''}
|
|
1019
|
+
onChange={(v) => updateAction(action.id, { varName: v })}
|
|
1020
|
+
variables={currentTask.variables}
|
|
1021
|
+
placeholder="allItems"
|
|
1022
|
+
/>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
)}
|
|
1027
|
+
|
|
1028
|
+
{action.type === 'stop' && (
|
|
1029
|
+
<div className="space-y-1.5">
|
|
1030
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Outcome</label>
|
|
1031
|
+
<select
|
|
1032
|
+
value={action.value || 'success'}
|
|
1033
|
+
onChange={(e) => updateAction(action.id, { value: e.target.value })}
|
|
1034
|
+
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[9px] font-bold uppercase tracking-[0.2em] text-white/70 focus:outline-none"
|
|
1035
|
+
>
|
|
1036
|
+
<option value="success">Success</option>
|
|
1037
|
+
<option value="error">Error</option>
|
|
1038
|
+
</select>
|
|
1039
|
+
</div>
|
|
1040
|
+
)}
|
|
1041
|
+
|
|
1042
|
+
{action.type === 'on_error' && (
|
|
1043
|
+
<div className="text-[8px] text-gray-600 uppercase tracking-widest">
|
|
1044
|
+
Runs if any action fails.
|
|
1045
|
+
</div>
|
|
1046
|
+
)}
|
|
1047
|
+
|
|
1048
|
+
{action.type === 'start' && (
|
|
1049
|
+
<div className="space-y-1.5">
|
|
1050
|
+
<label className="text-[7px] font-bold text-gray-600 uppercase tracking-widest pl-1">Task</label>
|
|
1051
|
+
<select
|
|
1052
|
+
value={action.value || ''}
|
|
1053
|
+
onChange={(e) => updateAction(action.id, { value: e.target.value })}
|
|
1054
|
+
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2 text-[9px] font-bold uppercase tracking-[0.2em] text-white/70 focus:outline-none"
|
|
1055
|
+
>
|
|
1056
|
+
<option value="" disabled>Select task</option>
|
|
1057
|
+
{availableTasks.length === 0 && (
|
|
1058
|
+
<option value="" disabled>No other tasks</option>
|
|
1059
|
+
)}
|
|
1060
|
+
{availableTasks.map((task) => (
|
|
1061
|
+
<option key={task.id} value={task.id}>
|
|
1062
|
+
{task.name || task.id}
|
|
1063
|
+
</option>
|
|
1064
|
+
))}
|
|
1065
|
+
</select>
|
|
1066
|
+
</div>
|
|
1067
|
+
)}
|
|
1068
|
+
</div>
|
|
1069
|
+
);
|
|
1070
|
+
});
|
|
1071
|
+
})()}
|
|
1072
|
+
{contextMenu && (() => {
|
|
1073
|
+
const targetIndex = currentTask.actions.findIndex(a => a.id === contextMenu.id);
|
|
1074
|
+
const target = currentTask.actions[targetIndex];
|
|
1075
|
+
if (!target) return null;
|
|
1076
|
+
return (
|
|
1077
|
+
<div
|
|
1078
|
+
className="action-context-menu fixed z-50 w-[200px] bg-[#0b0b0b] border border-white/10 rounded-xl shadow-2xl p-2 text-[10px] font-bold uppercase tracking-widest text-white/80"
|
|
1079
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
1080
|
+
>
|
|
1081
|
+
<button
|
|
1082
|
+
onClick={() => {
|
|
1083
|
+
updateAction(target.id, { disabled: !target.disabled });
|
|
1084
|
+
closeContextMenu();
|
|
1085
|
+
}}
|
|
1086
|
+
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 transition-colors"
|
|
1087
|
+
>
|
|
1088
|
+
{target.disabled ? 'Enable' : 'Disable'}
|
|
1089
|
+
</button>
|
|
1090
|
+
<button
|
|
1091
|
+
onClick={() => {
|
|
1092
|
+
removeAction(target.id);
|
|
1093
|
+
closeContextMenu();
|
|
1094
|
+
}}
|
|
1095
|
+
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 transition-colors text-red-400"
|
|
1096
|
+
>
|
|
1097
|
+
Delete
|
|
1098
|
+
</button>
|
|
1099
|
+
<button
|
|
1100
|
+
onClick={() => {
|
|
1101
|
+
setActionClipboard(createActionClone(target));
|
|
1102
|
+
removeAction(target.id);
|
|
1103
|
+
closeContextMenu();
|
|
1104
|
+
}}
|
|
1105
|
+
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 transition-colors"
|
|
1106
|
+
>
|
|
1107
|
+
Cut
|
|
1108
|
+
</button>
|
|
1109
|
+
<button
|
|
1110
|
+
onClick={() => {
|
|
1111
|
+
setActionClipboard(createActionClone(target));
|
|
1112
|
+
closeContextMenu();
|
|
1113
|
+
}}
|
|
1114
|
+
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 transition-colors"
|
|
1115
|
+
>
|
|
1116
|
+
Copy
|
|
1117
|
+
</button>
|
|
1118
|
+
<button
|
|
1119
|
+
onClick={() => {
|
|
1120
|
+
const clone = createActionClone(target);
|
|
1121
|
+
const next = [...currentTask.actions];
|
|
1122
|
+
next.splice(targetIndex + 1, 0, clone);
|
|
1123
|
+
setCurrentTask({ ...currentTask, actions: next });
|
|
1124
|
+
closeContextMenu();
|
|
1125
|
+
}}
|
|
1126
|
+
className="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 transition-colors"
|
|
1127
|
+
>
|
|
1128
|
+
Duplicate
|
|
1129
|
+
</button>
|
|
1130
|
+
</div>
|
|
1131
|
+
);
|
|
1132
|
+
})()}
|
|
1133
|
+
<button
|
|
1134
|
+
onClick={() => openActionPalette()}
|
|
1135
|
+
className="w-full py-3 border border-dashed border-white/20 rounded-xl text-[9px] font-bold uppercase tracking-widest text-gray-500 hover:text-white transition-all bg-white/[0.02]"
|
|
1136
|
+
>
|
|
1137
|
+
+ Append Action Seq
|
|
1138
|
+
</button>
|
|
1139
|
+
</div>
|
|
1140
|
+
|
|
1141
|
+
<div className="bg-white/5 rounded-xl p-4 border border-white/5 space-y-4">
|
|
1142
|
+
<h4 className="text-[9px] font-bold text-gray-400 uppercase tracking-widest border-b border-white/5 pb-2">Behavior Config</h4>
|
|
1143
|
+
<div className="grid grid-cols-2 gap-3">
|
|
1144
|
+
{Object.entries(currentTask.stealth).map(([key, val]) => (
|
|
1145
|
+
<label key={key} className="flex items-center gap-3 cursor-pointer group">
|
|
1146
|
+
<input
|
|
1147
|
+
type="checkbox"
|
|
1148
|
+
checked={val}
|
|
1149
|
+
onChange={(e) => setCurrentTask({
|
|
1150
|
+
...currentTask,
|
|
1151
|
+
stealth: { ...currentTask.stealth, [key]: e.target.checked },
|
|
1152
|
+
humanTyping: key === 'naturalTyping' ? e.target.checked : currentTask.humanTyping
|
|
1153
|
+
})}
|
|
1154
|
+
className="w-3 h-3 rounded bg-transparent border-white/20"
|
|
1155
|
+
/>
|
|
1156
|
+
<span className="text-[9px] font-bold text-gray-500 group-hover:text-white transition-all">
|
|
1157
|
+
{key.replace(/([A-Z])/g, ' $1').toUpperCase()}
|
|
1158
|
+
</span>
|
|
1159
|
+
</label>
|
|
1160
|
+
))}
|
|
1161
|
+
</div>
|
|
1162
|
+
</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
)}
|
|
1165
|
+
|
|
1166
|
+
<details className="border-t border-white/10 pt-6 font-sans">
|
|
1167
|
+
<summary className="cursor-pointer text-[9px] font-bold text-gray-500 uppercase tracking-[0.2em] hover:text-gray-400 transition-all">
|
|
1168
|
+
Variables (Injectable)
|
|
1169
|
+
</summary>
|
|
1170
|
+
<div className="space-y-3 mt-3">
|
|
1171
|
+
<div className="flex items-center justify-between">
|
|
1172
|
+
<p className="text-[8px] text-gray-600">Dynamic Params</p>
|
|
1173
|
+
<button onClick={addVariable} className="px-3 py-1 bg-white/5 border border-white/10 text-white text-[8px] font-bold rounded-lg uppercase tracking-widest hover:bg-white/10 transition-all">+ Add</button>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div className="space-y-2">
|
|
1176
|
+
{Object.entries(currentTask.variables).map(([name, def]) => (
|
|
1177
|
+
<VariableRow
|
|
1178
|
+
key={name}
|
|
1179
|
+
name={name}
|
|
1180
|
+
def={def}
|
|
1181
|
+
updateVariable={updateVariable}
|
|
1182
|
+
removeVariable={removeVariable}
|
|
1183
|
+
/>
|
|
1184
|
+
))}
|
|
1185
|
+
</div>
|
|
1186
|
+
</div>
|
|
1187
|
+
</details>
|
|
1188
|
+
|
|
1189
|
+
<details className="border-t border-white/10 pt-6 font-sans">
|
|
1190
|
+
<summary className="cursor-pointer text-[9px] font-bold text-gray-500 uppercase tracking-[0.2em] hover:text-gray-400 transition-all">
|
|
1191
|
+
Extraction Script
|
|
1192
|
+
</summary>
|
|
1193
|
+
<div className="space-y-3 mt-3">
|
|
1194
|
+
<div className="flex items-center justify-between">
|
|
1195
|
+
<span className="text-[8px] text-gray-600 uppercase tracking-widest">Output format</span>
|
|
1196
|
+
<select
|
|
1197
|
+
value={currentTask.extractionFormat || 'json'}
|
|
1198
|
+
onChange={(e) => setCurrentTask({ ...currentTask, extractionFormat: e.target.value as 'json' | 'csv' })}
|
|
1199
|
+
className="custom-select bg-white/[0.05] border border-white/10 rounded-lg px-3 py-2 text-[8px] font-bold uppercase text-white/60"
|
|
1200
|
+
>
|
|
1201
|
+
<option value="json">JSON</option>
|
|
1202
|
+
<option value="csv">CSV</option>
|
|
1203
|
+
</select>
|
|
1204
|
+
</div>
|
|
1205
|
+
<p className="text-[8px] text-gray-600">Process scraped HTML with JavaScript. Use <code className="text-blue-400 bg-white/5 px-1 py-0.5 rounded">$$data.html()</code> to access the raw HTML.</p>
|
|
1206
|
+
<div className="w-full bg-[#050505] border border-white/10 rounded-xl p-4 font-mono text-xs text-green-300 focus-within:border-white/30 resize-none custom-scrollbar leading-relaxed min-h-[200px] whitespace-pre-wrap">
|
|
1207
|
+
<RichInput
|
|
1208
|
+
value={currentTask.extractionScript || ''}
|
|
1209
|
+
onChange={(val) => setCurrentTask({ ...currentTask, extractionScript: val })}
|
|
1210
|
+
variables={currentTask.variables}
|
|
1211
|
+
syntax="javascript"
|
|
1212
|
+
placeholder={`// Example: Extract all links
|
|
1213
|
+
const html = $$data.html();
|
|
1214
|
+
const parser = new DOMParser();
|
|
1215
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
1216
|
+
const links = Array.from(doc.querySelectorAll('a')).map(a => a.href);
|
|
1217
|
+
return JSON.stringify(links, null, 2);`}
|
|
1218
|
+
/>
|
|
1219
|
+
</div>
|
|
1220
|
+
</div>
|
|
1221
|
+
</details>
|
|
1222
|
+
|
|
1223
|
+
{currentTask.mode === 'scrape' && (
|
|
1224
|
+
<div className="space-y-2">
|
|
1225
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">Selector Filter</label>
|
|
1226
|
+
<div className="w-full bg-white/[0.05] border border-white/10 rounded-xl px-4 py-3 text-sm focus-within:border-white/30 transition-all">
|
|
1227
|
+
<RichInput
|
|
1228
|
+
value={currentTask.selector || ''}
|
|
1229
|
+
onChange={(val) => setCurrentTask({ ...currentTask, selector: val })}
|
|
1230
|
+
variables={currentTask.variables}
|
|
1231
|
+
placeholder=".main-content"
|
|
1232
|
+
/>
|
|
1233
|
+
</div>
|
|
1234
|
+
</div>
|
|
1235
|
+
)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
<div className="pt-4 border-t border-white/10 space-y-3">
|
|
1239
|
+
<label className="flex items-center gap-3 p-3.5 rounded-2xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.05] transition-all cursor-pointer group">
|
|
1240
|
+
<input
|
|
1241
|
+
type="checkbox"
|
|
1242
|
+
checked={currentTask.rotateUserAgents}
|
|
1243
|
+
onChange={(e) => setCurrentTask({ ...currentTask, rotateUserAgents: e.target.checked })}
|
|
1244
|
+
className="w-4 h-4 rounded border-white/20 bg-transparent"
|
|
1245
|
+
/>
|
|
1246
|
+
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest group-hover:text-white">Rotate UA</span>
|
|
1247
|
+
</label>
|
|
1248
|
+
<label className="flex items-center gap-3 p-3.5 rounded-2xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.05] transition-all cursor-pointer group">
|
|
1249
|
+
<input
|
|
1250
|
+
type="checkbox"
|
|
1251
|
+
checked={currentTask.includeShadowDom !== false}
|
|
1252
|
+
onChange={(e) => setCurrentTask({ ...currentTask, includeShadowDom: e.target.checked })}
|
|
1253
|
+
className="w-4 h-4 rounded border-white/20 bg-transparent"
|
|
1254
|
+
/>
|
|
1255
|
+
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest group-hover:text-white">Include Shadow DOM in HTML</span>
|
|
1256
|
+
</label>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
)}
|
|
1260
|
+
|
|
1261
|
+
{editorView === 'json' && (
|
|
1262
|
+
<JsonEditorPane
|
|
1263
|
+
task={currentTask}
|
|
1264
|
+
onChange={setCurrentTask}
|
|
1265
|
+
onCopy={(text, id) => { void handleCopy(text, id); }}
|
|
1266
|
+
copiedId={copied}
|
|
1267
|
+
/>
|
|
1268
|
+
)}
|
|
1269
|
+
|
|
1270
|
+
{
|
|
1271
|
+
editorView === 'api' && (
|
|
1272
|
+
<div className="h-full flex flex-col">
|
|
1273
|
+
<div className="space-y-6 flex-1 flex flex-col min-h-0">
|
|
1274
|
+
<div className="space-y-2">
|
|
1275
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">Deployment Endpoint</label>
|
|
1276
|
+
<div className="flex gap-2 items-center">
|
|
1277
|
+
<input
|
|
1278
|
+
type="text"
|
|
1279
|
+
readOnly
|
|
1280
|
+
value={currentTask.id ? `${window.location.origin}/tasks/${currentTask.id}/api` : 'Save task to view endpoint'}
|
|
1281
|
+
className="flex-1 bg-[#050505] border border-white/10 rounded-xl px-4 py-2 font-mono text-xs text-green-300 focus:outline-none"
|
|
1282
|
+
/>
|
|
1283
|
+
<button
|
|
1284
|
+
onClick={() => {
|
|
1285
|
+
const url = currentTask.id ? `${window.location.origin}/tasks/${currentTask.id}/api` : '';
|
|
1286
|
+
if (url) void handleCopy(url, 'endpoint');
|
|
1287
|
+
}}
|
|
1288
|
+
className={`px-4 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 ${copied === 'endpoint' ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-white/5 border-white/10 text-white hover:bg-white/10'}`}
|
|
1289
|
+
>
|
|
1290
|
+
{copied === 'endpoint' ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
1291
|
+
{copied === 'endpoint' ? 'Copied' : 'Copy'}
|
|
1292
|
+
</button>
|
|
1293
|
+
</div>
|
|
1294
|
+
</div>
|
|
1295
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
1296
|
+
<div className="flex items-center justify-between mb-2">
|
|
1297
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-widest">Override Variables (JSON)</label>
|
|
1298
|
+
<button onClick={() => {
|
|
1299
|
+
const cleanVars: Record<string, any> = {};
|
|
1300
|
+
Object.entries(currentTask.variables).forEach(([n, d]) => cleanVars[n] = d.value);
|
|
1301
|
+
void handleCopy(JSON.stringify({ variables: cleanVars }, null, 2), 'vars');
|
|
1302
|
+
}} className={`px-4 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 ${copied === 'vars' ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-white/5 border-white/10 text-white hover:bg-white/10'}`}>
|
|
1303
|
+
{copied === 'vars' ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
1304
|
+
{copied === 'vars' ? 'Copied' : 'Copy'}
|
|
1305
|
+
</button>
|
|
1306
|
+
</div>
|
|
1307
|
+
<CodeEditor
|
|
1308
|
+
readOnly
|
|
1309
|
+
value={(() => {
|
|
1310
|
+
const cleanVars: Record<string, any> = {};
|
|
1311
|
+
Object.entries(currentTask.variables).forEach(([n, d]) => cleanVars[n] = d.value);
|
|
1312
|
+
return JSON.stringify({ variables: cleanVars }, null, 2);
|
|
1313
|
+
})()}
|
|
1314
|
+
language="json"
|
|
1315
|
+
className="flex-1"
|
|
1316
|
+
/>
|
|
1317
|
+
</div>
|
|
1318
|
+
</div>
|
|
1319
|
+
<p className="text-[8px] text-gray-600 mt-4 font-mono uppercase tracking-widest leading-loose">Automate via HTTP POST to the above endpoint with your API key in the headers.</p>
|
|
1320
|
+
</div>
|
|
1321
|
+
)
|
|
1322
|
+
}
|
|
1323
|
+
{editorView === 'history' && (
|
|
1324
|
+
<div className="space-y-6">
|
|
1325
|
+
<div className="flex items-center justify-between">
|
|
1326
|
+
<span className="text-[9px] font-bold text-gray-400 uppercase tracking-widest">Task Versions</span>
|
|
1327
|
+
<div className="flex items-center gap-2">
|
|
1328
|
+
<button
|
|
1329
|
+
onClick={loadVersions}
|
|
1330
|
+
className="px-4 py-2 border border-white/10 text-[9px] font-bold rounded-xl uppercase tracking-widest text-white hover:bg-white/5 transition-all"
|
|
1331
|
+
>
|
|
1332
|
+
Refresh
|
|
1333
|
+
</button>
|
|
1334
|
+
<button
|
|
1335
|
+
onClick={async () => {
|
|
1336
|
+
if (!currentTask.id) return;
|
|
1337
|
+
const confirmed = await onConfirm('Clear all task versions?');
|
|
1338
|
+
if (!confirmed) return;
|
|
1339
|
+
const res = await fetch(`/api/tasks/${currentTask.id}/versions/clear`, { method: 'POST' });
|
|
1340
|
+
if (res.ok) {
|
|
1341
|
+
onNotify('Version history cleared.', 'success');
|
|
1342
|
+
loadVersions();
|
|
1343
|
+
} else {
|
|
1344
|
+
onNotify('Clear failed.', 'error');
|
|
1345
|
+
}
|
|
1346
|
+
}}
|
|
1347
|
+
className="px-4 py-2 border border-red-500/20 text-[9px] font-bold rounded-xl uppercase tracking-widest text-red-300 hover:bg-red-500/10 transition-all"
|
|
1348
|
+
>
|
|
1349
|
+
Clear
|
|
1350
|
+
</button>
|
|
1351
|
+
</div>
|
|
1352
|
+
</div>
|
|
1353
|
+
{versionsLoading && (
|
|
1354
|
+
<div className="text-[9px] text-gray-500 uppercase tracking-widest">Loading versions...</div>
|
|
1355
|
+
)}
|
|
1356
|
+
{!versionsLoading && versions.length === 0 && (
|
|
1357
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">No versions yet. Save changes to create history.</div>
|
|
1358
|
+
)}
|
|
1359
|
+
<div className="space-y-3">
|
|
1360
|
+
{versions.map((version) => (
|
|
1361
|
+
<div key={version.id} className="glass-card p-4 rounded-2xl flex items-center justify-between">
|
|
1362
|
+
<div className="space-y-1">
|
|
1363
|
+
<div className="text-[10px] font-bold text-white uppercase tracking-widest">{version.name}</div>
|
|
1364
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
1365
|
+
{new Date(version.timestamp).toLocaleString()} | {version.mode}
|
|
1366
|
+
</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
<div className="flex items-center gap-2">
|
|
1369
|
+
<button
|
|
1370
|
+
onClick={() => openVersionPreview(version.id)}
|
|
1371
|
+
disabled={versionPreviewLoading}
|
|
1372
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1373
|
+
>
|
|
1374
|
+
{versionPreviewLoading ? 'Loading...' : 'View'}
|
|
1375
|
+
</button>
|
|
1376
|
+
<button
|
|
1377
|
+
onClick={() => rollbackToVersion(version.id)}
|
|
1378
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition-all"
|
|
1379
|
+
>
|
|
1380
|
+
Rollback
|
|
1381
|
+
</button>
|
|
1382
|
+
</div>
|
|
1383
|
+
</div>
|
|
1384
|
+
))}
|
|
1385
|
+
</div>
|
|
1386
|
+
</div>
|
|
1387
|
+
)}
|
|
1388
|
+
</div >
|
|
1389
|
+
<ActionPalette
|
|
1390
|
+
open={actionPaletteOpen}
|
|
1391
|
+
query={actionPaletteQuery}
|
|
1392
|
+
onQueryChange={setActionPaletteQuery}
|
|
1393
|
+
onClose={() => setActionPaletteOpen(false)}
|
|
1394
|
+
onSelect={(type) => {
|
|
1395
|
+
if (actionPaletteTargetId) {
|
|
1396
|
+
if (type === 'if') {
|
|
1397
|
+
updateAction(actionPaletteTargetId, {
|
|
1398
|
+
type,
|
|
1399
|
+
conditionVar: '',
|
|
1400
|
+
conditionVarType: 'string',
|
|
1401
|
+
conditionOp: 'equals',
|
|
1402
|
+
conditionValue: ''
|
|
1403
|
+
});
|
|
1404
|
+
} else {
|
|
1405
|
+
updateAction(actionPaletteTargetId, { type });
|
|
1406
|
+
}
|
|
1407
|
+
} else {
|
|
1408
|
+
addActionByType(type);
|
|
1409
|
+
}
|
|
1410
|
+
setActionPaletteOpen(false);
|
|
1411
|
+
}}
|
|
1412
|
+
/>
|
|
1413
|
+
|
|
1414
|
+
<div className="p-8 border-t border-white/10 backdrop-blur-xl shrink-0">
|
|
1415
|
+
<div className="flex items-center gap-3">
|
|
1416
|
+
<button
|
|
1417
|
+
onClick={onRun}
|
|
1418
|
+
disabled={isExecuting && currentTask.mode !== 'headful'}
|
|
1419
|
+
className="shine-effect flex-1 bg-white text-black py-4 rounded-2xl font-bold text-[10px] tracking-[0.3em] uppercase transition-all shadow-xl shadow-white/5 flex items-center justify-center gap-3 disabled:opacity-50"
|
|
1420
|
+
>
|
|
1421
|
+
{isExecuting && currentTask.mode !== 'headful' ? (
|
|
1422
|
+
<div className="w-3 h-3 border-2 border-black/20 border-t-black rounded-full animate-spin" />
|
|
1423
|
+
) : <Play className="w-3 h-3 fill-black" />}
|
|
1424
|
+
<span>
|
|
1425
|
+
{isExecuting && currentTask.mode === 'headful' ? 'Stop Headful' : isExecuting ? 'Running...' : 'Run Task'}
|
|
1426
|
+
</span>
|
|
1427
|
+
</button>
|
|
1428
|
+
{isExecuting && (
|
|
1429
|
+
<button
|
|
1430
|
+
onClick={() => onStop?.()}
|
|
1431
|
+
className="w-12 h-12 rounded-2xl border border-white/10 text-white/80 hover:text-white hover:bg-white/10 transition-all flex items-center justify-center"
|
|
1432
|
+
title="Stop task"
|
|
1433
|
+
>
|
|
1434
|
+
<Square className="w-4 h-4" />
|
|
1435
|
+
</button>
|
|
1436
|
+
)}
|
|
1437
|
+
</div>
|
|
1438
|
+
</div>
|
|
1439
|
+
</aside >
|
|
1440
|
+
<div
|
|
1441
|
+
className="w-2 cursor-col-resize bg-white/5 hover:bg-white/10 transition-colors"
|
|
1442
|
+
onPointerDown={(event) => {
|
|
1443
|
+
event.preventDefault();
|
|
1444
|
+
resizingRef.current = true;
|
|
1445
|
+
document.body.style.cursor = 'col-resize';
|
|
1446
|
+
document.body.style.userSelect = 'none';
|
|
1447
|
+
}}
|
|
1448
|
+
/>
|
|
1449
|
+
|
|
1450
|
+
<main className="flex-1 overflow-y-auto custom-scrollbar bg-[#020202] p-12 relative">
|
|
1451
|
+
<div className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
|
1452
|
+
style={{ backgroundImage: 'radial-gradient(#fff 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
|
1453
|
+
|
|
1454
|
+
<ResultsPane
|
|
1455
|
+
results={results}
|
|
1456
|
+
pinnedResults={pinnedResults}
|
|
1457
|
+
isExecuting={isExecuting}
|
|
1458
|
+
isHeadful={currentTask.mode === 'headful'}
|
|
1459
|
+
onConfirm={onConfirm}
|
|
1460
|
+
onNotify={onNotify}
|
|
1461
|
+
onPin={onPinResults}
|
|
1462
|
+
onUnpin={onUnpinResults}
|
|
1463
|
+
fullWidth={currentTask.mode === 'headful'}
|
|
1464
|
+
/>
|
|
1465
|
+
{versionPreview && (
|
|
1466
|
+
<div className="fixed inset-0 z-[210] flex items-center justify-center bg-black/70 backdrop-blur-sm px-6">
|
|
1467
|
+
<div className="glass-card w-full max-w-6xl rounded-[32px] border border-white/10 p-8 shadow-2xl flex flex-col max-h-[90vh]">
|
|
1468
|
+
<div className="flex items-center justify-between border-b border-white/5 pb-4 mb-6">
|
|
1469
|
+
<div className="space-y-1">
|
|
1470
|
+
<div className="text-[9px] font-bold text-gray-500 uppercase tracking-[0.3em]">Task Snapshot</div>
|
|
1471
|
+
<div className="text-lg font-bold text-white">{versionPreview.snapshot.name}</div>
|
|
1472
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
1473
|
+
{new Date(versionPreview.timestamp).toLocaleString()} | {versionPreview.snapshot.mode}
|
|
1474
|
+
</div>
|
|
1475
|
+
</div>
|
|
1476
|
+
<div className="flex items-center gap-2">
|
|
1477
|
+
<button
|
|
1478
|
+
onClick={() => setVersionPreview(null)}
|
|
1479
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-white/5 border border-white/10 text-white hover:bg-white/10 transition-all"
|
|
1480
|
+
>
|
|
1481
|
+
Close
|
|
1482
|
+
</button>
|
|
1483
|
+
<button
|
|
1484
|
+
onClick={() => {
|
|
1485
|
+
if (onRunSnapshot) onRunSnapshot(versionPreview.snapshot);
|
|
1486
|
+
setVersionPreview(null);
|
|
1487
|
+
}}
|
|
1488
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-white text-black hover:bg-white/90 transition-all"
|
|
1489
|
+
>
|
|
1490
|
+
Run Version
|
|
1491
|
+
</button>
|
|
1492
|
+
</div>
|
|
1493
|
+
</div>
|
|
1494
|
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 overflow-y-auto custom-scrollbar pr-2 flex-1 min-h-0">
|
|
1495
|
+
<div className="space-y-2">
|
|
1496
|
+
<div className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Snapshot JSON</div>
|
|
1497
|
+
<CodeEditor
|
|
1498
|
+
readOnly
|
|
1499
|
+
value={JSON.stringify(versionPreview.snapshot, null, 2)}
|
|
1500
|
+
language="json"
|
|
1501
|
+
className="min-h-[320px]"
|
|
1502
|
+
/>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div className="space-y-2">
|
|
1505
|
+
<div className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Output</div>
|
|
1506
|
+
<div className="glass-card rounded-2xl p-6 border border-white/10 text-[10px] text-gray-500">
|
|
1507
|
+
No output captured for this snapshot yet. Run this version to see results.
|
|
1508
|
+
</div>
|
|
1509
|
+
</div>
|
|
1510
|
+
</div>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
)}
|
|
1514
|
+
</main>
|
|
1515
|
+
</div >
|
|
1516
|
+
);
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
export default EditorScreen;
|