@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,115 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
3
|
+
import { Execution, Results, ConfirmRequest } from '../types';
|
|
4
|
+
import ResultsPane from './editor/ResultsPane';
|
|
5
|
+
|
|
6
|
+
interface ExecutionDetailScreenProps {
|
|
7
|
+
onConfirm: (request: string | ConfirmRequest) => Promise<boolean>;
|
|
8
|
+
onNotify: (message: string, tone?: 'success' | 'error') => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const toResults = (exec: Execution): Results | null => {
|
|
12
|
+
if (!exec.result) return null;
|
|
13
|
+
const result = exec.result || {};
|
|
14
|
+
return {
|
|
15
|
+
url: exec.url || result.url || '',
|
|
16
|
+
finalUrl: result.final_url || result.finalUrl,
|
|
17
|
+
html: result.html,
|
|
18
|
+
data: result.data ?? result.html ?? '',
|
|
19
|
+
screenshotUrl: result.screenshot_url || result.screenshotUrl,
|
|
20
|
+
logs: result.logs || [],
|
|
21
|
+
timestamp: new Date(exec.timestamp).toLocaleTimeString()
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ExecutionDetailScreen: React.FC<ExecutionDetailScreenProps> = ({ onConfirm, onNotify }) => {
|
|
26
|
+
const { id } = useParams();
|
|
27
|
+
const navigate = useNavigate();
|
|
28
|
+
const [execution, setExecution] = useState<Execution | null>(null);
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const loadExecution = async () => {
|
|
33
|
+
if (!id) return;
|
|
34
|
+
setLoading(true);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`/api/executions/${id}`);
|
|
37
|
+
if (!res.ok) throw new Error('Failed to load execution');
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
setExecution(data.execution || null);
|
|
40
|
+
} catch {
|
|
41
|
+
setExecution(null);
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
loadExecution();
|
|
47
|
+
}, [id]);
|
|
48
|
+
|
|
49
|
+
if (loading) {
|
|
50
|
+
return (
|
|
51
|
+
<main className="flex-1 p-12 overflow-y-auto custom-scrollbar animate-in fade-in duration-500">
|
|
52
|
+
<div className="max-w-6xl mx-auto text-[9px] text-gray-500 uppercase tracking-widest">Loading execution...</div>
|
|
53
|
+
</main>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!execution) {
|
|
58
|
+
return (
|
|
59
|
+
<main className="flex-1 p-12 overflow-y-auto custom-scrollbar animate-in fade-in duration-500">
|
|
60
|
+
<div className="max-w-6xl mx-auto space-y-6">
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => navigate('/executions')}
|
|
63
|
+
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"
|
|
64
|
+
>
|
|
65
|
+
Back
|
|
66
|
+
</button>
|
|
67
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">Execution not found.</div>
|
|
68
|
+
</div>
|
|
69
|
+
</main>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const results = toResults(execution);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<main className="flex-1 p-12 overflow-y-auto custom-scrollbar animate-in fade-in duration-500">
|
|
77
|
+
<div className="max-w-6xl mx-auto space-y-8">
|
|
78
|
+
<div className="flex items-end justify-between">
|
|
79
|
+
<div className="space-y-2">
|
|
80
|
+
<p className="text-[10px] font-bold text-blue-400 uppercase tracking-[0.4em]">Execution</p>
|
|
81
|
+
<h2 className="text-3xl font-bold tracking-tighter text-white">{execution.taskName || execution.mode}</h2>
|
|
82
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
83
|
+
{new Date(execution.timestamp).toLocaleString()} | {execution.source} | {execution.mode} | {execution.status} | {execution.durationMs}ms
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => navigate('/executions')}
|
|
88
|
+
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"
|
|
89
|
+
>
|
|
90
|
+
Back
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div className="glass-card rounded-[32px] p-8 flex flex-col min-h-[420px]">
|
|
95
|
+
<div className="flex items-center justify-between border-b border-white/5 pb-4 mb-6">
|
|
96
|
+
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Output</span>
|
|
97
|
+
</div>
|
|
98
|
+
{results ? (
|
|
99
|
+
<ResultsPane
|
|
100
|
+
results={results}
|
|
101
|
+
isExecuting={false}
|
|
102
|
+
onConfirm={onConfirm}
|
|
103
|
+
onNotify={onNotify}
|
|
104
|
+
fullWidth
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="text-[9px] text-gray-500 uppercase tracking-widest">No output captured.</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</main>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default ExecutionDetailScreen;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { RefreshCw, Trash2, Monitor, Cloud } from 'lucide-react';
|
|
4
|
+
import { Execution, ConfirmRequest } from '../types';
|
|
5
|
+
|
|
6
|
+
interface ExecutionsScreenProps {
|
|
7
|
+
onConfirm: (request: string | ConfirmRequest) => Promise<boolean>;
|
|
8
|
+
onNotify: (message: string, tone?: 'success' | 'error') => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ExecutionsScreen: React.FC<ExecutionsScreenProps> = ({ onConfirm, onNotify }) => {
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
const [executions, setExecutions] = useState<Execution[]>([]);
|
|
14
|
+
const [filter, setFilter] = useState<'all' | 'editor' | 'api'>('all');
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
|
|
17
|
+
const loadExecutions = async () => {
|
|
18
|
+
setLoading(true);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch('/api/executions');
|
|
21
|
+
if (!res.ok) throw new Error('Failed to load');
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
setExecutions(Array.isArray(data.executions) ? data.executions : []);
|
|
24
|
+
} catch {
|
|
25
|
+
setExecutions([]);
|
|
26
|
+
} finally {
|
|
27
|
+
setLoading(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const clearExecutions = async () => {
|
|
32
|
+
const confirmed = await onConfirm('Clear all executions?');
|
|
33
|
+
if (!confirmed) return;
|
|
34
|
+
const res = await fetch('/api/executions/clear', { method: 'POST' });
|
|
35
|
+
if (res.ok) {
|
|
36
|
+
onNotify('Executions cleared.', 'success');
|
|
37
|
+
loadExecutions();
|
|
38
|
+
} else {
|
|
39
|
+
onNotify('Clear failed.', 'error');
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const deleteExecution = async (id: string) => {
|
|
44
|
+
const confirmed = await onConfirm('Delete this execution?');
|
|
45
|
+
if (!confirmed) return;
|
|
46
|
+
const res = await fetch(`/api/executions/${id}`, { method: 'DELETE' });
|
|
47
|
+
if (res.ok) {
|
|
48
|
+
onNotify('Execution deleted.', 'success');
|
|
49
|
+
setExecutions((prev) => prev.filter((e) => e.id !== id));
|
|
50
|
+
} else {
|
|
51
|
+
onNotify('Delete failed.', 'error');
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
loadExecutions();
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const filtered = executions.filter((exec) => {
|
|
60
|
+
if (filter === 'all') return true;
|
|
61
|
+
return exec.source === filter;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<main className="flex-1 p-12 overflow-y-auto custom-scrollbar animate-in fade-in duration-500">
|
|
66
|
+
<div className="max-w-6xl mx-auto space-y-8">
|
|
67
|
+
<div className="flex items-end justify-between">
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
<p className="text-[10px] font-bold text-blue-400 uppercase tracking-[0.4em]">Executions</p>
|
|
70
|
+
<h2 className="text-4xl font-bold tracking-tighter text-white">Run History</h2>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="flex items-center gap-3">
|
|
73
|
+
<div className="flex bg-white/5 rounded-xl p-1 border border-white/5">
|
|
74
|
+
{(['all', 'editor', 'api'] as const).map((mode) => (
|
|
75
|
+
<button
|
|
76
|
+
key={mode}
|
|
77
|
+
onClick={() => setFilter(mode)}
|
|
78
|
+
className={`px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-lg transition-all ${filter === mode ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
79
|
+
>
|
|
80
|
+
{mode}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
<button
|
|
85
|
+
onClick={loadExecutions}
|
|
86
|
+
className="w-10 h-10 rounded-2xl border border-white/10 text-gray-400 hover:text-white hover:bg-white/5 transition-all flex items-center justify-center"
|
|
87
|
+
title="Refresh"
|
|
88
|
+
>
|
|
89
|
+
<RefreshCw className="w-4 h-4" />
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={clearExecutions}
|
|
93
|
+
className="w-10 h-10 rounded-2xl border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-all flex items-center justify-center"
|
|
94
|
+
title="Clear all"
|
|
95
|
+
>
|
|
96
|
+
<Trash2 className="w-4 h-4" />
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{loading && (
|
|
102
|
+
<div className="text-[9px] text-gray-500 uppercase tracking-widest">Loading executions...</div>
|
|
103
|
+
)}
|
|
104
|
+
{!loading && filtered.length === 0 && (
|
|
105
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">No executions found.</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="space-y-3">
|
|
109
|
+
{filtered.map((exec) => (
|
|
110
|
+
<div
|
|
111
|
+
key={exec.id}
|
|
112
|
+
onClick={() => navigate(`/executions/${exec.id}`)}
|
|
113
|
+
onKeyDown={(event) => {
|
|
114
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
navigate(`/executions/${exec.id}`);
|
|
117
|
+
}
|
|
118
|
+
}}
|
|
119
|
+
role="button"
|
|
120
|
+
tabIndex={0}
|
|
121
|
+
className="glass-card w-full rounded-2xl p-5 flex items-center gap-4 text-left hover:bg-white/[0.06] transition-all cursor-pointer"
|
|
122
|
+
>
|
|
123
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400">
|
|
124
|
+
{exec.source === 'api' ? <Cloud className="w-5 h-5" /> : <Monitor className="w-5 h-5" />}
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex-1 min-w-0 space-y-1">
|
|
127
|
+
<div className="text-[10px] font-bold text-white uppercase tracking-widest truncate">
|
|
128
|
+
{exec.taskName || exec.mode}
|
|
129
|
+
</div>
|
|
130
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
131
|
+
{new Date(exec.timestamp).toLocaleString()} | {exec.source} | {exec.mode} | {exec.status} | {exec.durationMs}ms
|
|
132
|
+
</div>
|
|
133
|
+
{exec.url && (
|
|
134
|
+
<div className="text-[9px] text-white/50 truncate font-mono">
|
|
135
|
+
{exec.url}
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
<button
|
|
140
|
+
onClick={(event) => {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
deleteExecution(exec.id);
|
|
143
|
+
}}
|
|
144
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-red-500/5 border border-red-500/10 text-red-400 hover:bg-red-500/10 transition-all"
|
|
145
|
+
>
|
|
146
|
+
Delete
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</main>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export default ExecutionsScreen;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface LoadingScreenProps {
|
|
2
|
+
title?: string;
|
|
3
|
+
subtitle?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const LoadingScreen: React.FC<LoadingScreenProps> = ({ title = 'Loading', subtitle }) => {
|
|
7
|
+
return (
|
|
8
|
+
<div className="fixed inset-0 z-[80] bg-[#020202] flex items-center justify-center">
|
|
9
|
+
<div className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
|
10
|
+
style={{ backgroundImage: 'radial-gradient(#fff 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
|
11
|
+
<div className="glass-card p-10 rounded-[40px] text-center space-y-4">
|
|
12
|
+
<div className="w-12 h-12 mx-auto rounded-full border border-white/10 flex items-center justify-center">
|
|
13
|
+
<div className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
|
14
|
+
</div>
|
|
15
|
+
<div className="space-y-2">
|
|
16
|
+
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-500">{title}</p>
|
|
17
|
+
{subtitle && (
|
|
18
|
+
<p className="text-xs font-mono text-white/80">{subtitle}</p>
|
|
19
|
+
)}
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default LoadingScreen;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MoveLeft } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface NotFoundScreenProps {
|
|
4
|
+
title?: string;
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
onBack?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const NotFoundScreen: React.FC<NotFoundScreenProps> = ({
|
|
10
|
+
title = 'Not Found',
|
|
11
|
+
subtitle = 'The page you requested does not exist.',
|
|
12
|
+
onBack
|
|
13
|
+
}) => {
|
|
14
|
+
return (
|
|
15
|
+
<div className="h-full flex items-center justify-center px-10">
|
|
16
|
+
<div className="glass-card w-full max-w-2xl rounded-[40px] border border-white/10 p-10 text-center space-y-6">
|
|
17
|
+
<div className="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-500">404</div>
|
|
18
|
+
<h2 className="text-3xl font-bold tracking-tighter text-white">{title}</h2>
|
|
19
|
+
<p className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-500">{subtitle}</p>
|
|
20
|
+
{onBack && (
|
|
21
|
+
<button
|
|
22
|
+
onClick={onBack}
|
|
23
|
+
className="mt-2 inline-flex items-center gap-2 px-6 py-3 rounded-2xl bg-white text-black text-[9px] font-bold uppercase tracking-[0.3em] hover:scale-105 transition-all"
|
|
24
|
+
>
|
|
25
|
+
<MoveLeft className="w-4 h-4" />
|
|
26
|
+
Back
|
|
27
|
+
</button>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default NotFoundScreen;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
import { Variable } from '../types';
|
|
3
|
+
import { highlightCode, SyntaxLanguage } from '../utils/syntaxHighlight';
|
|
4
|
+
|
|
5
|
+
interface RichInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (val: string) => void;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
variables: Record<string, Variable>;
|
|
10
|
+
className?: string;
|
|
11
|
+
syntax?: SyntaxLanguage;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RichInput: React.FC<RichInputProps> = ({ value, onChange, placeholder, variables, className, syntax = 'plain' }) => {
|
|
15
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (ref.current) {
|
|
19
|
+
const currentHtml = ref.current.innerHTML;
|
|
20
|
+
const targetHtml = highlightCode(value, syntax, variables);
|
|
21
|
+
if (currentHtml !== targetHtml) {
|
|
22
|
+
const selection = window.getSelection();
|
|
23
|
+
let offset = 0;
|
|
24
|
+
if (selection && selection.rangeCount > 0) {
|
|
25
|
+
const range = selection.getRangeAt(0);
|
|
26
|
+
const preRange = range.cloneRange();
|
|
27
|
+
preRange.selectNodeContents(ref.current);
|
|
28
|
+
preRange.setEnd(range.endContainer, range.endOffset);
|
|
29
|
+
offset = preRange.toString().length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ref.current.innerHTML = targetHtml;
|
|
33
|
+
|
|
34
|
+
if (offset > 0) {
|
|
35
|
+
const walker = document.createTreeWalker(ref.current, NodeFilter.SHOW_TEXT);
|
|
36
|
+
let charCount = 0;
|
|
37
|
+
let node = walker.nextNode();
|
|
38
|
+
while (node) {
|
|
39
|
+
const length = node.textContent?.length || 0;
|
|
40
|
+
if (charCount + length >= offset) {
|
|
41
|
+
const range = document.createRange();
|
|
42
|
+
range.setStart(node, offset - charCount);
|
|
43
|
+
range.collapse(true);
|
|
44
|
+
selection?.removeAllRanges();
|
|
45
|
+
selection?.addRange(range);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
charCount += length;
|
|
49
|
+
node = walker.nextNode();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, [value, variables]);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
ref={ref}
|
|
59
|
+
contentEditable
|
|
60
|
+
className={`rich-input-content w-full bg-transparent focus:outline-none text-white min-h-[1.5rem] ${className}`}
|
|
61
|
+
data-placeholder={placeholder}
|
|
62
|
+
onInput={(e) => onChange(e.currentTarget.textContent || '')}
|
|
63
|
+
onBlur={(e) => onChange(e.currentTarget.textContent || '')}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default RichInput;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { ConfirmRequest } from '../types';
|
|
3
|
+
import ApiKeyPanel from './settings/ApiKeyPanel';
|
|
4
|
+
import StoragePanel from './settings/StoragePanel';
|
|
5
|
+
import ScreenshotsPanel from './settings/ScreenshotsPanel';
|
|
6
|
+
import CookiesPanel from './settings/CookiesPanel';
|
|
7
|
+
import SettingsHeader from './settings/SettingsHeader';
|
|
8
|
+
import LayoutPanel from './settings/LayoutPanel';
|
|
9
|
+
|
|
10
|
+
interface SettingsScreenProps {
|
|
11
|
+
onClearStorage: (type: 'screenshots' | 'cookies') => void;
|
|
12
|
+
onConfirm: (request: string | ConfirmRequest) => Promise<boolean>;
|
|
13
|
+
onNotify: (message: string, tone?: 'success' | 'error') => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
|
17
|
+
onClearStorage,
|
|
18
|
+
onConfirm,
|
|
19
|
+
onNotify
|
|
20
|
+
}) => {
|
|
21
|
+
const [tab, setTab] = useState<'system' | 'data'>('system');
|
|
22
|
+
const [screenshots, setScreenshots] = useState<{ name: string; url: string; size: number; modified: number }[]>([]);
|
|
23
|
+
const [cookies, setCookies] = useState<{ name: string; value: string; domain?: string; path?: string; expires?: number }[]>([]);
|
|
24
|
+
const [cookieOrigins, setCookieOrigins] = useState<any[]>([]);
|
|
25
|
+
const [dataLoading, setDataLoading] = useState(false);
|
|
26
|
+
const [apiKey, setApiKey] = useState<string | null>(null);
|
|
27
|
+
const [apiKeyLoading, setApiKeyLoading] = useState(false);
|
|
28
|
+
const [apiKeySaving, setApiKeySaving] = useState(false);
|
|
29
|
+
const [layoutSplitPercent, setLayoutSplitPercent] = useState(30);
|
|
30
|
+
|
|
31
|
+
const layoutStorageKey = 'doppelganger.layout.leftWidthPct';
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
try {
|
|
35
|
+
const stored = localStorage.getItem(layoutStorageKey);
|
|
36
|
+
if (stored) {
|
|
37
|
+
const value = Math.min(75, Math.max(25, Math.round(parseFloat(stored) * 100)));
|
|
38
|
+
if (!Number.isNaN(value)) setLayoutSplitPercent(value);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const loadData = async () => {
|
|
46
|
+
setDataLoading(true);
|
|
47
|
+
try {
|
|
48
|
+
const [shotsRes, cookiesRes] = await Promise.all([
|
|
49
|
+
fetch('/api/data/screenshots'),
|
|
50
|
+
fetch('/api/data/cookies')
|
|
51
|
+
]);
|
|
52
|
+
const shotsData = shotsRes.ok ? await shotsRes.json() : { screenshots: [] };
|
|
53
|
+
const cookiesData = cookiesRes.ok ? await cookiesRes.json() : { cookies: [], origins: [] };
|
|
54
|
+
setScreenshots(Array.isArray(shotsData.screenshots) ? shotsData.screenshots : []);
|
|
55
|
+
setCookies(Array.isArray(cookiesData.cookies) ? cookiesData.cookies : []);
|
|
56
|
+
setCookieOrigins(Array.isArray(cookiesData.origins) ? cookiesData.origins : []);
|
|
57
|
+
} catch {
|
|
58
|
+
setScreenshots([]);
|
|
59
|
+
setCookies([]);
|
|
60
|
+
setCookieOrigins([]);
|
|
61
|
+
} finally {
|
|
62
|
+
setDataLoading(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const deleteScreenshot = async (name: string) => {
|
|
67
|
+
const confirmed = await onConfirm(`Delete screenshot ${name}?`);
|
|
68
|
+
if (!confirmed) return;
|
|
69
|
+
const res = await fetch(`/api/data/screenshots/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
onNotify('Screenshot deleted.', 'success');
|
|
72
|
+
loadData();
|
|
73
|
+
} else {
|
|
74
|
+
onNotify('Delete failed.', 'error');
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const deleteCookie = async (cookie: { name: string; domain?: string; path?: string }) => {
|
|
79
|
+
const confirmed = await onConfirm(`Delete cookie ${cookie.name}?`);
|
|
80
|
+
if (!confirmed) return;
|
|
81
|
+
const res = await fetch('/api/data/cookies/delete', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ name: cookie.name, domain: cookie.domain, path: cookie.path })
|
|
85
|
+
});
|
|
86
|
+
if (res.ok) {
|
|
87
|
+
onNotify('Cookie deleted.', 'success');
|
|
88
|
+
loadData();
|
|
89
|
+
} else {
|
|
90
|
+
onNotify('Delete failed.', 'error');
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const loadApiKey = async () => {
|
|
95
|
+
setApiKeyLoading(true);
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch('/api/settings/api-key', { credentials: 'include' });
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
if (res.status === 401) {
|
|
100
|
+
onNotify('Session expired. Please log in again.', 'error');
|
|
101
|
+
}
|
|
102
|
+
setApiKey(null);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const data = await res.json();
|
|
106
|
+
setApiKey(data.apiKey || null);
|
|
107
|
+
} catch {
|
|
108
|
+
setApiKey(null);
|
|
109
|
+
} finally {
|
|
110
|
+
setApiKeyLoading(false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const regenerateApiKey = async () => {
|
|
115
|
+
setApiKeySaving(true);
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch('/api/settings/api-key', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
credentials: 'include'
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
let detail = '';
|
|
124
|
+
try {
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
detail = data?.error || data?.message || '';
|
|
127
|
+
} catch {
|
|
128
|
+
detail = '';
|
|
129
|
+
}
|
|
130
|
+
if (res.status === 401) {
|
|
131
|
+
onNotify('Session expired. Please log in again.', 'error');
|
|
132
|
+
} else {
|
|
133
|
+
onNotify(`Failed to generate API key${detail ? `: ${detail}` : ''}.`, 'error');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const data = await res.json();
|
|
138
|
+
setApiKey(data.apiKey || null);
|
|
139
|
+
onNotify('API key generated.', 'success');
|
|
140
|
+
} catch {
|
|
141
|
+
onNotify('Failed to generate API key.', 'error');
|
|
142
|
+
} finally {
|
|
143
|
+
setApiKeySaving(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const copyApiKey = async () => {
|
|
148
|
+
if (!apiKey) return;
|
|
149
|
+
try {
|
|
150
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
151
|
+
await navigator.clipboard.writeText(apiKey);
|
|
152
|
+
} else {
|
|
153
|
+
const textarea = document.createElement('textarea');
|
|
154
|
+
textarea.value = apiKey;
|
|
155
|
+
textarea.style.position = 'fixed';
|
|
156
|
+
textarea.style.opacity = '0';
|
|
157
|
+
document.body.appendChild(textarea);
|
|
158
|
+
textarea.select();
|
|
159
|
+
const ok = document.execCommand('copy');
|
|
160
|
+
document.body.removeChild(textarea);
|
|
161
|
+
if (!ok) throw new Error('Copy failed');
|
|
162
|
+
}
|
|
163
|
+
onNotify('API key copied.', 'success');
|
|
164
|
+
} catch {
|
|
165
|
+
onNotify('Copy failed.', 'error');
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (tab === 'data') loadData();
|
|
171
|
+
if (tab === 'system') loadApiKey();
|
|
172
|
+
}, [tab]);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
try {
|
|
176
|
+
localStorage.setItem(layoutStorageKey, String(layoutSplitPercent / 100));
|
|
177
|
+
} catch {
|
|
178
|
+
// ignore
|
|
179
|
+
}
|
|
180
|
+
}, [layoutSplitPercent]);
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<main className="flex-1 p-12 overflow-y-auto custom-scrollbar animate-in fade-in duration-500">
|
|
184
|
+
<div className="max-w-3xl mx-auto space-y-8">
|
|
185
|
+
<SettingsHeader tab={tab} onTabChange={setTab} />
|
|
186
|
+
|
|
187
|
+
{tab === 'system' && (
|
|
188
|
+
<>
|
|
189
|
+
<ApiKeyPanel
|
|
190
|
+
apiKey={apiKey}
|
|
191
|
+
loading={apiKeyLoading}
|
|
192
|
+
saving={apiKeySaving}
|
|
193
|
+
onRegenerate={regenerateApiKey}
|
|
194
|
+
onCopy={copyApiKey}
|
|
195
|
+
/>
|
|
196
|
+
<LayoutPanel
|
|
197
|
+
splitPercent={layoutSplitPercent}
|
|
198
|
+
onChange={setLayoutSplitPercent}
|
|
199
|
+
onReset={() => setLayoutSplitPercent(30)}
|
|
200
|
+
/>
|
|
201
|
+
<StoragePanel onClearStorage={onClearStorage} />
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{tab === 'data' && (
|
|
206
|
+
<>
|
|
207
|
+
<ScreenshotsPanel
|
|
208
|
+
screenshots={screenshots}
|
|
209
|
+
loading={dataLoading}
|
|
210
|
+
onRefresh={loadData}
|
|
211
|
+
onDelete={deleteScreenshot}
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
<CookiesPanel
|
|
215
|
+
cookies={cookies}
|
|
216
|
+
originsCount={cookieOrigins.length}
|
|
217
|
+
loading={dataLoading}
|
|
218
|
+
onClear={() => onClearStorage('cookies')}
|
|
219
|
+
onDelete={deleteCookie}
|
|
220
|
+
/>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</main>
|
|
225
|
+
);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export default SettingsScreen;
|