@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,105 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Bot, Save, Eye, EyeOff } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface AgentAiPanelProps {
|
|
5
|
+
apiKey: string | null;
|
|
6
|
+
model: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
saving: boolean;
|
|
10
|
+
onSave: (next: { apiKey: string; model: string; enabled: boolean }) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AgentAiPanel: React.FC<AgentAiPanelProps> = ({ apiKey, model, enabled, loading, saving, onSave }) => {
|
|
14
|
+
const [localKey, setLocalKey] = useState(apiKey || '');
|
|
15
|
+
const [localModel, setLocalModel] = useState(model || 'gpt-5-nano');
|
|
16
|
+
const [visible, setVisible] = useState(false);
|
|
17
|
+
const [localEnabled, setLocalEnabled] = useState(enabled);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setLocalKey(apiKey || '');
|
|
21
|
+
}, [apiKey]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setLocalModel(model || 'gpt-5-nano');
|
|
25
|
+
}, [model]);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setLocalEnabled(enabled);
|
|
29
|
+
}, [enabled]);
|
|
30
|
+
|
|
31
|
+
const submit = () => {
|
|
32
|
+
onSave({
|
|
33
|
+
apiKey: localKey.trim(),
|
|
34
|
+
model: localModel.trim() || 'gpt-5-nano',
|
|
35
|
+
enabled: localEnabled
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
41
|
+
<div className="flex items-center gap-4 mb-2">
|
|
42
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400"><Bot className="w-5 h-5" /></div>
|
|
43
|
+
<div>
|
|
44
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">Agent AI</h3>
|
|
45
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Bring your own OpenAI key</p>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="space-y-4">
|
|
50
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">OpenAI API Key</label>
|
|
51
|
+
<div className="flex items-center gap-3 rounded-2xl bg-black/40 border border-white/10 px-4 py-2">
|
|
52
|
+
<input
|
|
53
|
+
type={visible ? 'text' : 'password'}
|
|
54
|
+
value={loading ? 'Loading...' : localKey}
|
|
55
|
+
onChange={(e) => setLocalKey(e.target.value)}
|
|
56
|
+
disabled={loading}
|
|
57
|
+
placeholder="sk-..."
|
|
58
|
+
className="flex-1 bg-transparent text-[10px] text-blue-200/80 font-mono focus:outline-none"
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => setVisible((prev) => !prev)}
|
|
62
|
+
className="p-2 rounded-xl border border-white/10 text-white/70 hover:text-white hover:bg-white/5 transition-all"
|
|
63
|
+
title={visible ? 'Hide key' : 'Show key'}
|
|
64
|
+
aria-label={visible ? 'Hide key' : 'Show key'}
|
|
65
|
+
type="button"
|
|
66
|
+
>
|
|
67
|
+
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
<label className="text-[9px] font-bold text-gray-400 uppercase tracking-[0.2em]">Model</label>
|
|
71
|
+
<input
|
|
72
|
+
type="text"
|
|
73
|
+
value={localModel}
|
|
74
|
+
onChange={(e) => setLocalModel(e.target.value)}
|
|
75
|
+
placeholder="gpt-5-nano"
|
|
76
|
+
className="w-full bg-white/[0.05] border border-white/10 rounded-2xl px-4 py-2 text-[10px] text-white font-mono focus:outline-none focus:border-white/30 transition-all"
|
|
77
|
+
/>
|
|
78
|
+
<label className="flex items-center gap-3 p-3 rounded-2xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.05] transition-all cursor-pointer group">
|
|
79
|
+
<input
|
|
80
|
+
type="checkbox"
|
|
81
|
+
checked={localEnabled}
|
|
82
|
+
onChange={(e) => setLocalEnabled(e.target.checked)}
|
|
83
|
+
className="w-4 h-4 rounded border-white/20 bg-transparent"
|
|
84
|
+
/>
|
|
85
|
+
<span className="text-[9px] font-bold text-gray-500 uppercase tracking-widest group-hover:text-white">Enable AI workflow generation</span>
|
|
86
|
+
</label>
|
|
87
|
+
<div className="flex gap-3">
|
|
88
|
+
<button
|
|
89
|
+
onClick={submit}
|
|
90
|
+
disabled={saving || loading}
|
|
91
|
+
className="flex-1 px-6 py-3 rounded-2xl text-[9px] font-bold uppercase tracking-widest bg-white text-black hover:scale-105 transition-all disabled:opacity-60 disabled:hover:scale-100 inline-flex items-center justify-center gap-2"
|
|
92
|
+
>
|
|
93
|
+
<Save className="w-4 h-4" />
|
|
94
|
+
Save AI Settings
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<p className="text-[9px] text-gray-600 uppercase tracking-widest">
|
|
98
|
+
Used by Agent Tasks to generate workflow blocks from steps.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export default AgentAiPanel;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Copy, Database, Eye, EyeOff } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface ApiKeyPanelProps {
|
|
5
|
+
apiKey: string | null;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
saving: boolean;
|
|
8
|
+
onRegenerate: () => void;
|
|
9
|
+
onCopy: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ApiKeyPanel: React.FC<ApiKeyPanelProps> = ({ apiKey, loading, saving, onRegenerate, onCopy }) => {
|
|
13
|
+
const [visible, setVisible] = useState(false);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
17
|
+
<div className="flex items-center gap-4 mb-2">
|
|
18
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400"><Database className="w-5 h-5" /></div>
|
|
19
|
+
<div>
|
|
20
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">API Key</h3>
|
|
21
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Manage task API access</p>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-col gap-4">
|
|
25
|
+
<div className="rounded-2xl bg-black/40 border border-white/10 px-4 py-3 font-mono text-[10px] text-blue-200/80 break-all min-h-[44px] flex items-center justify-between gap-3">
|
|
26
|
+
<span className={apiKey && !visible ? 'text-[14px] leading-none' : undefined}>
|
|
27
|
+
{loading
|
|
28
|
+
? 'Loading...'
|
|
29
|
+
: (apiKey
|
|
30
|
+
? (visible ? apiKey : '••••••••••••••••••••••••••••••••••••••••')
|
|
31
|
+
: 'No API key set')}
|
|
32
|
+
</span>
|
|
33
|
+
<button
|
|
34
|
+
onClick={() => setVisible((prev) => !prev)}
|
|
35
|
+
disabled={!apiKey}
|
|
36
|
+
className="p-2 rounded-xl border border-white/10 text-white/70 hover:text-white hover:bg-white/5 transition-all disabled:opacity-50"
|
|
37
|
+
title={visible ? 'Hide key' : 'Show key'}
|
|
38
|
+
aria-label={visible ? 'Hide key' : 'Show key'}
|
|
39
|
+
>
|
|
40
|
+
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="flex gap-3">
|
|
44
|
+
<button
|
|
45
|
+
onClick={onRegenerate}
|
|
46
|
+
disabled={saving}
|
|
47
|
+
className="flex-1 px-6 py-3 rounded-2xl text-[9px] font-bold uppercase tracking-widest bg-white text-black hover:scale-105 transition-all disabled:opacity-60 disabled:hover:scale-100"
|
|
48
|
+
>
|
|
49
|
+
{apiKey ? 'Rotate Key' : 'Generate Key'}
|
|
50
|
+
</button>
|
|
51
|
+
<button
|
|
52
|
+
onClick={onCopy}
|
|
53
|
+
disabled={!apiKey}
|
|
54
|
+
className="flex-1 px-6 py-3 rounded-2xl text-[9px] font-bold uppercase tracking-widest border border-white/10 text-white hover:bg-white/5 transition-all disabled:opacity-50 inline-flex items-center justify-center gap-2"
|
|
55
|
+
>
|
|
56
|
+
<Copy className="w-4 h-4" />
|
|
57
|
+
Copy Key
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
<p className="text-[9px] text-gray-600 uppercase tracking-widest">
|
|
61
|
+
Use this key in `key`, `x-api-key`, or `Authorization: Bearer` headers.
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default ApiKeyPanel;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Database } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface CookieEntry {
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
domain?: string;
|
|
8
|
+
path?: string;
|
|
9
|
+
expires?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CookiesPanelProps {
|
|
13
|
+
cookies: CookieEntry[];
|
|
14
|
+
originsCount: number;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
onClear: () => void;
|
|
17
|
+
onDelete: (cookie: { name: string; domain?: string; path?: string }) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CookiesPanel: React.FC<CookiesPanelProps> = ({ cookies, originsCount, loading, onClear, onDelete }) => {
|
|
21
|
+
const [expandedCookies, setExpandedCookies] = useState<Record<string, boolean>>({});
|
|
22
|
+
const [decodedCookies, setDecodedCookies] = useState<Record<string, boolean>>({});
|
|
23
|
+
|
|
24
|
+
const cookieKey = (cookie: CookieEntry) => {
|
|
25
|
+
return `${cookie.name}|${cookie.domain || ''}|${cookie.path || ''}|${cookie.expires || ''}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isMostlyPrintable = (value: string) => {
|
|
29
|
+
if (!value) return false;
|
|
30
|
+
let printable = 0;
|
|
31
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
32
|
+
const code = value.charCodeAt(i);
|
|
33
|
+
if (code === 9 || code === 10 || code === 13 || (code >= 32 && code <= 126)) {
|
|
34
|
+
printable += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return printable / value.length >= 0.85;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const decodeCookieValue = (value: string) => {
|
|
41
|
+
if (!value) return null;
|
|
42
|
+
if (/%[0-9A-Fa-f]{2}/.test(value)) {
|
|
43
|
+
try {
|
|
44
|
+
const decoded = decodeURIComponent(value);
|
|
45
|
+
if (decoded !== value && isMostlyPrintable(decoded)) {
|
|
46
|
+
return { value: decoded, kind: 'URL' as const };
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore invalid URI sequences
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (/^[A-Za-z0-9+/=]+$/.test(value) && value.length >= 12 && value.length % 4 === 0) {
|
|
53
|
+
try {
|
|
54
|
+
const decoded = atob(value);
|
|
55
|
+
if (decoded && isMostlyPrintable(decoded)) {
|
|
56
|
+
return { value: decoded, kind: 'Base64' as const };
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore invalid base64
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const toggleCookie = (cookie: CookieEntry) => {
|
|
66
|
+
const key = cookieKey(cookie);
|
|
67
|
+
setExpandedCookies((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const toggleDecodedCookie = (cookie: CookieEntry) => {
|
|
71
|
+
const key = cookieKey(cookie);
|
|
72
|
+
setDecodedCookies((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
77
|
+
<div className="flex items-center justify-between">
|
|
78
|
+
<div className="flex items-center gap-4">
|
|
79
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400"><Database className="w-5 h-5" /></div>
|
|
80
|
+
<div>
|
|
81
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">Cookies</h3>
|
|
82
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Browser storage state</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<button
|
|
86
|
+
onClick={onClear}
|
|
87
|
+
className="px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl bg-yellow-500/5 border border-yellow-500/10 text-yellow-400 hover:bg-yellow-500/10 transition-all"
|
|
88
|
+
>
|
|
89
|
+
Clear Cookies
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
{loading && <div className="text-[9px] text-gray-500 uppercase tracking-widest">Loading data...</div>}
|
|
93
|
+
{!loading && cookies.length === 0 && (
|
|
94
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">No cookies found.</div>
|
|
95
|
+
)}
|
|
96
|
+
<div className="space-y-3">
|
|
97
|
+
{cookies.map((cookie) => {
|
|
98
|
+
const key = cookieKey(cookie);
|
|
99
|
+
const isExpanded = !!expandedCookies[key];
|
|
100
|
+
const value = cookie.value || '';
|
|
101
|
+
const decodedCandidate = decodeCookieValue(value);
|
|
102
|
+
const showDecoded = !!decodedCandidate && !!decodedCookies[key];
|
|
103
|
+
const fullValue = showDecoded && decodedCandidate ? decodedCandidate.value : value;
|
|
104
|
+
const displayValue = isExpanded || fullValue.length <= 120
|
|
105
|
+
? fullValue
|
|
106
|
+
: `${fullValue.slice(0, 120)}...`;
|
|
107
|
+
return (
|
|
108
|
+
<div key={key} className="bg-white/5 border border-white/10 rounded-2xl p-4 space-y-3">
|
|
109
|
+
<div className="flex items-center justify-between gap-4">
|
|
110
|
+
<div className="min-w-0">
|
|
111
|
+
<div className="text-[10px] font-bold text-white uppercase tracking-widest truncate">{cookie.name}</div>
|
|
112
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
113
|
+
{(cookie.domain || 'local')} | {(cookie.path || '/')}
|
|
114
|
+
{cookie.expires ? ` | ${new Date(cookie.expires * 1000).toLocaleString()}` : ''}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
{decodedCandidate && (
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => toggleDecodedCookie(cookie)}
|
|
121
|
+
className="px-3 py-2 text-[9px] font-bold uppercase tracking-widest rounded-xl border border-white/10 text-white/70 hover:text-white hover:bg-white/5 transition-all"
|
|
122
|
+
>
|
|
123
|
+
{showDecoded ? 'Show Raw' : `Decode ${decodedCandidate.kind}`}
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => onDelete(cookie)}
|
|
128
|
+
className="px-3 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"
|
|
129
|
+
>
|
|
130
|
+
Delete
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div
|
|
135
|
+
onClick={() => toggleCookie(cookie)}
|
|
136
|
+
className="cursor-pointer rounded-xl bg-black/40 border border-white/10 px-3 py-2 font-mono text-[10px] text-blue-200/80 whitespace-pre-wrap break-words"
|
|
137
|
+
title="Click to expand/collapse"
|
|
138
|
+
>
|
|
139
|
+
{displayValue || '(empty)'}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
{!loading && originsCount > 0 && (
|
|
146
|
+
<div className="pt-2 text-[8px] text-gray-600 uppercase tracking-widest">
|
|
147
|
+
Origins stored: {originsCount}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default CookiesPanel;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
interface LayoutPanelProps {
|
|
2
|
+
splitPercent: number;
|
|
3
|
+
onChange: (value: number) => void;
|
|
4
|
+
onReset: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const LayoutPanel: React.FC<LayoutPanelProps> = ({ splitPercent, onChange, onReset }) => {
|
|
8
|
+
const safeSplit = Number.isFinite(splitPercent) ? Math.min(75, Math.max(25, splitPercent)) : 30;
|
|
9
|
+
return (
|
|
10
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
11
|
+
<div className="flex items-center justify-between">
|
|
12
|
+
<div>
|
|
13
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">Layout</h3>
|
|
14
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Default editor split</p>
|
|
15
|
+
</div>
|
|
16
|
+
<button
|
|
17
|
+
onClick={onReset}
|
|
18
|
+
className="px-4 py-2 border border-white/10 text-[9px] font-bold rounded-xl uppercase tracking-widest text-white/70 hover:text-white hover:bg-white/5 transition-all"
|
|
19
|
+
>
|
|
20
|
+
Reset 30/70
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="space-y-3">
|
|
24
|
+
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-widest text-gray-500">
|
|
25
|
+
<span>Editor</span>
|
|
26
|
+
<span>{safeSplit}%</span>
|
|
27
|
+
</div>
|
|
28
|
+
<input
|
|
29
|
+
type="range"
|
|
30
|
+
min={25}
|
|
31
|
+
max={75}
|
|
32
|
+
step={5}
|
|
33
|
+
value={safeSplit}
|
|
34
|
+
onChange={(e) => onChange(Math.min(75, Math.max(25, Number(e.target.value))))}
|
|
35
|
+
className="w-full accent-white"
|
|
36
|
+
/>
|
|
37
|
+
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-widest text-gray-500">
|
|
38
|
+
<span>Preview</span>
|
|
39
|
+
<span>{100 - safeSplit}%</span>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default LayoutPanel;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Image as ImageIcon } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface ScreenshotEntry {
|
|
4
|
+
name: string;
|
|
5
|
+
url: string;
|
|
6
|
+
size: number;
|
|
7
|
+
modified: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ScreenshotsPanelProps {
|
|
11
|
+
screenshots: ScreenshotEntry[];
|
|
12
|
+
loading: boolean;
|
|
13
|
+
onRefresh: () => void;
|
|
14
|
+
onDelete: (name: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ScreenshotsPanel: React.FC<ScreenshotsPanelProps> = ({ screenshots, loading, onRefresh, onDelete }) => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
20
|
+
<div className="flex items-center justify-between">
|
|
21
|
+
<div className="flex items-center gap-4">
|
|
22
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400"><ImageIcon className="w-5 h-5" /></div>
|
|
23
|
+
<div>
|
|
24
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">Screenshots</h3>
|
|
25
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Stored captures</p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<button
|
|
29
|
+
onClick={onRefresh}
|
|
30
|
+
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"
|
|
31
|
+
>
|
|
32
|
+
Refresh
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
{loading && <div className="text-[9px] text-gray-500 uppercase tracking-widest">Loading data...</div>}
|
|
36
|
+
{!loading && screenshots.length === 0 && (
|
|
37
|
+
<div className="text-[9px] text-gray-600 uppercase tracking-widest">No screenshots found.</div>
|
|
38
|
+
)}
|
|
39
|
+
<div className="space-y-3">
|
|
40
|
+
{screenshots.map((shot) => (
|
|
41
|
+
<div key={shot.name} className="bg-white/5 border border-white/10 rounded-2xl p-4 flex items-center gap-4">
|
|
42
|
+
<div className="w-16 h-16 bg-black rounded-xl overflow-hidden shrink-0 border border-white/10">
|
|
43
|
+
<img src={shot.url} className="w-full h-full object-cover" />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex-1 min-w-0">
|
|
46
|
+
<div className="text-[10px] font-bold text-white uppercase tracking-widest truncate">{shot.name}</div>
|
|
47
|
+
<div className="text-[8px] text-gray-500 uppercase tracking-[0.2em]">
|
|
48
|
+
{new Date(shot.modified).toLocaleString()} | {(shot.size / 1024).toFixed(1)} KB
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => onDelete(shot.name)}
|
|
53
|
+
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"
|
|
54
|
+
>
|
|
55
|
+
Delete
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default ScreenshotsPanel;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
interface SettingsHeaderProps {
|
|
2
|
+
tab: 'system' | 'data';
|
|
3
|
+
onTabChange: (tab: 'system' | 'data') => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SettingsHeader: React.FC<SettingsHeaderProps> = ({ tab, onTabChange }) => {
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex items-end justify-between mb-8">
|
|
9
|
+
<div className="space-y-2">
|
|
10
|
+
<p className="text-[10px] font-bold text-purple-500 uppercase tracking-[0.4em]">System</p>
|
|
11
|
+
<h2 className="text-4xl font-bold tracking-tighter text-white">Settings</h2>
|
|
12
|
+
</div>
|
|
13
|
+
<div className="flex bg-white/5 rounded-xl p-1 border border-white/5">
|
|
14
|
+
{(['system', 'data'] as const).map((t) => (
|
|
15
|
+
<button
|
|
16
|
+
key={t}
|
|
17
|
+
onClick={() => onTabChange(t)}
|
|
18
|
+
className={`px-4 py-2 text-[9px] font-bold uppercase tracking-widest rounded-lg transition-all ${tab === t ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
19
|
+
>
|
|
20
|
+
{t}
|
|
21
|
+
</button>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default SettingsHeader;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Trash2 } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
interface StoragePanelProps {
|
|
4
|
+
onClearStorage: (type: 'screenshots' | 'cookies') => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const StoragePanel: React.FC<StoragePanelProps> = ({ onClearStorage }) => {
|
|
8
|
+
return (
|
|
9
|
+
<div className="glass-card p-8 rounded-[40px] space-y-6">
|
|
10
|
+
<div className="flex items-center gap-4 mb-2">
|
|
11
|
+
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-gray-400"><Trash2 className="w-5 h-5" /></div>
|
|
12
|
+
<div>
|
|
13
|
+
<h3 className="text-sm font-bold text-white uppercase tracking-widest">Storage</h3>
|
|
14
|
+
<p className="text-[10px] text-gray-500 uppercase tracking-widest mt-1">Manage stored data</p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="flex gap-4">
|
|
18
|
+
<button
|
|
19
|
+
onClick={() => onClearStorage('screenshots')}
|
|
20
|
+
className="flex-1 px-6 py-4 bg-red-500/5 border border-red-500/10 text-red-400 rounded-2xl text-[9px] font-bold uppercase tracking-widest hover:bg-red-500/10 transition-all"
|
|
21
|
+
>
|
|
22
|
+
Clear Screenshots
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
onClick={() => onClearStorage('cookies')}
|
|
26
|
+
className="flex-1 px-6 py-4 bg-yellow-500/5 border border-yellow-500/10 text-yellow-400 rounded-2xl text-[9px] font-bold uppercase tracking-widest hover:bg-yellow-500/10 transition-all"
|
|
27
|
+
>
|
|
28
|
+
Reset Cookies
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default StoragePanel;
|