draply-dev 1.2.1 → 1.3.0
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/bin/cli.js +223 -12
- package/package.json +1 -1
- package/src/draply-features.js +251 -5
package/bin/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const https = require('https');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const zlib = require('zlib');
|
|
@@ -13,7 +14,6 @@ const targetHost = args[2] || 'localhost';
|
|
|
13
14
|
|
|
14
15
|
const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
|
|
15
16
|
const FEATURES_JS = fs.readFileSync(path.join(__dirname, '../src/draply-features.js'), 'utf8');
|
|
16
|
-
// Unique marker that does NOT appear inside overlay.js itself
|
|
17
17
|
const MARKER = 'data-ps-done="1"';
|
|
18
18
|
const INJECT_TAG = `\n<link rel="stylesheet" href="/draply.css">\n<script ${MARKER}>\n${OVERLAY_JS}\n</script>\n<script data-draply-features="1">\n${FEATURES_JS}\n</script>`;
|
|
19
19
|
|
|
@@ -35,7 +35,7 @@ function decode(headers, chunks) {
|
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
//
|
|
38
|
+
// ── Project & Config ──────────────────────────────────────────────────────────
|
|
39
39
|
const projectRoot = process.cwd();
|
|
40
40
|
const draplyDir = path.join(projectRoot, '.draply');
|
|
41
41
|
if (!fs.existsSync(draplyDir)) {
|
|
@@ -49,19 +49,232 @@ if (!fs.existsSync(draplyDir)) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
const overridesPath = path.join(draplyDir, 'overrides.css');
|
|
52
|
-
if (!fs.existsSync(overridesPath))
|
|
53
|
-
|
|
52
|
+
if (!fs.existsSync(overridesPath)) fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
|
|
53
|
+
|
|
54
|
+
const configPath = path.join(draplyDir, 'config.json');
|
|
55
|
+
function loadConfig() {
|
|
56
|
+
try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; }
|
|
57
|
+
}
|
|
58
|
+
function saveConfig(cfg) {
|
|
59
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
63
|
+
function walkDir(dir, exts, results = []) {
|
|
64
|
+
try {
|
|
65
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const fp = path.join(dir, e.name);
|
|
68
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === '.next' || e.name === 'build') continue;
|
|
69
|
+
if (e.isDirectory()) walkDir(fp, exts, results);
|
|
70
|
+
else if (exts.some(x => e.name.endsWith(x))) results.push(fp);
|
|
71
|
+
}
|
|
72
|
+
} catch { /* skip */ }
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Find best source file for a className ─────────────────────────────────────
|
|
77
|
+
function findFileForClass(root, className) {
|
|
78
|
+
const files = walkDir(root, ['.jsx', '.tsx', '.js', '.ts', '.css', '.scss', '.vue', '.svelte']);
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
try {
|
|
81
|
+
const content = fs.readFileSync(f, 'utf8');
|
|
82
|
+
if (content.includes(className)) {
|
|
83
|
+
return { file: f, content, relativePath: path.relative(root, f) };
|
|
84
|
+
}
|
|
85
|
+
} catch { /* skip */ }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Extract class name from CSS selector ──────────────────────────────────────
|
|
91
|
+
function extractClassName(selector) {
|
|
92
|
+
const parts = selector.split('>').map(s => s.trim());
|
|
93
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
94
|
+
const m = parts[i].match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/);
|
|
95
|
+
if (m) return m[1];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Call Gemini API ───────────────────────────────────────────────────────────
|
|
101
|
+
function callGemini(apiKey, prompt) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
|
|
104
|
+
const body = JSON.stringify({
|
|
105
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
106
|
+
generationConfig: { temperature: 0.1, maxOutputTokens: 8192 }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const parsed = new URL(url);
|
|
110
|
+
const options = {
|
|
111
|
+
hostname: parsed.hostname,
|
|
112
|
+
path: parsed.pathname + parsed.search,
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const req = https.request(options, res => {
|
|
118
|
+
const chunks = [];
|
|
119
|
+
res.on('data', c => chunks.push(c));
|
|
120
|
+
res.on('end', () => {
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
123
|
+
if (data.error) {
|
|
124
|
+
reject(new Error(data.error.message || 'Gemini API error'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
128
|
+
resolve(text);
|
|
129
|
+
} catch (e) { reject(e); }
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
req.on('error', reject);
|
|
133
|
+
req.write(body);
|
|
134
|
+
req.end();
|
|
135
|
+
});
|
|
54
136
|
}
|
|
55
137
|
|
|
138
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
139
|
+
// HTTP SERVER
|
|
140
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
56
141
|
const server = http.createServer((req, res) => {
|
|
57
142
|
|
|
58
|
-
// CORS preflight
|
|
59
143
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
60
144
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
|
61
145
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
62
146
|
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
63
147
|
|
|
64
|
-
// ──
|
|
148
|
+
// ── Config endpoint (get/set API key) ───────────────────────────────────────
|
|
149
|
+
if (req.url === '/draply-config') {
|
|
150
|
+
if (req.method === 'GET') {
|
|
151
|
+
const cfg = loadConfig();
|
|
152
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
+
// Mask API key for security — only send if it exists
|
|
154
|
+
res.end(JSON.stringify({ hasKey: !!cfg.apiKey, provider: cfg.provider || 'gemini' }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (req.method === 'POST') {
|
|
158
|
+
let body = '';
|
|
159
|
+
req.on('data', c => body += c);
|
|
160
|
+
req.on('end', () => {
|
|
161
|
+
try {
|
|
162
|
+
const { apiKey, provider } = JSON.parse(body);
|
|
163
|
+
const cfg = loadConfig();
|
|
164
|
+
if (apiKey !== undefined) cfg.apiKey = apiKey;
|
|
165
|
+
if (provider) cfg.provider = provider;
|
|
166
|
+
saveConfig(cfg);
|
|
167
|
+
console.log(` \x1b[32m✓\x1b[0m API key saved (${cfg.provider || 'gemini'})`);
|
|
168
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
169
|
+
res.end(JSON.stringify({ ok: true }));
|
|
170
|
+
} catch (e) {
|
|
171
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── AI Apply endpoint ───────────────────────────────────────────────────────
|
|
180
|
+
if (req.url === '/draply-ai-apply' && req.method === 'POST') {
|
|
181
|
+
let body = '';
|
|
182
|
+
req.on('data', c => body += c);
|
|
183
|
+
req.on('end', async () => {
|
|
184
|
+
try {
|
|
185
|
+
const { changes } = JSON.parse(body);
|
|
186
|
+
const cfg = loadConfig();
|
|
187
|
+
if (!cfg.apiKey) {
|
|
188
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ ok: false, error: 'No API key configured. Click ⚙ to set up.' }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const results = [];
|
|
194
|
+
|
|
195
|
+
// Group changes by class
|
|
196
|
+
for (const ch of (changes || [])) {
|
|
197
|
+
if (!ch.selector || !ch.props) continue;
|
|
198
|
+
|
|
199
|
+
const className = extractClassName(ch.selector);
|
|
200
|
+
if (!className) {
|
|
201
|
+
results.push({ selector: ch.selector, ok: false, reason: 'No class name found' });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Find the source file
|
|
206
|
+
const found = findFileForClass(projectRoot, className);
|
|
207
|
+
if (!found) {
|
|
208
|
+
results.push({ selector: ch.selector, ok: false, reason: `"${className}" not found in source files` });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(` \x1b[36m🤖\x1b[0m AI applying: .${className} → ${found.relativePath}`);
|
|
213
|
+
|
|
214
|
+
// Build the prompt
|
|
215
|
+
const propsStr = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
216
|
+
const prompt = `You are a code editor. Modify the following source file to apply CSS style changes.
|
|
217
|
+
|
|
218
|
+
FILE: ${found.relativePath}
|
|
219
|
+
\`\`\`
|
|
220
|
+
${found.content}
|
|
221
|
+
\`\`\`
|
|
222
|
+
|
|
223
|
+
CHANGES TO APPLY:
|
|
224
|
+
The element with class "${className}" should have these CSS properties:
|
|
225
|
+
${propsStr}
|
|
226
|
+
|
|
227
|
+
RULES:
|
|
228
|
+
- If the file is CSS/SCSS: find the .${className} rule and update/add the properties. If the rule doesn't exist, add it.
|
|
229
|
+
- If the file is JSX/TSX: apply changes in the most appropriate way for this file:
|
|
230
|
+
- If the file already uses inline styles for this element, update the style prop
|
|
231
|
+
- If the file imports a CSS file, add the rule to that CSS file instead (tell me the filename in a comment)
|
|
232
|
+
- If the file uses Tailwind classes, update the className with appropriate Tailwind classes
|
|
233
|
+
- Otherwise, add a style prop to the element with className="${className}"
|
|
234
|
+
- Keep ALL existing code intact. Only modify what's needed for the style changes.
|
|
235
|
+
- Do NOT add comments explaining what you changed.
|
|
236
|
+
|
|
237
|
+
Return ONLY the complete modified file content. No markdown fences, no explanations, just the raw code.`;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const result = await callGemini(cfg.apiKey, prompt);
|
|
241
|
+
|
|
242
|
+
// Clean up response — remove markdown fences if AI added them
|
|
243
|
+
let code = result.trim();
|
|
244
|
+
if (code.startsWith('```')) {
|
|
245
|
+
code = code.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Safety: don't write empty or tiny responses
|
|
249
|
+
if (code.length < 20) {
|
|
250
|
+
results.push({ selector: ch.selector, ok: false, reason: 'AI returned empty/invalid response' });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write the modified file
|
|
255
|
+
fs.writeFileSync(found.file, code, 'utf8');
|
|
256
|
+
console.log(` \x1b[32m✓\x1b[0m Applied to ${found.relativePath}`);
|
|
257
|
+
results.push({ selector: ch.selector, ok: true, file: found.relativePath });
|
|
258
|
+
|
|
259
|
+
} catch (aiErr) {
|
|
260
|
+
console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
|
|
261
|
+
results.push({ selector: ch.selector, ok: false, reason: aiErr.message });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const applied = results.filter(r => r.ok).length;
|
|
266
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
267
|
+
res.end(JSON.stringify({ ok: true, applied, total: results.length, results }));
|
|
268
|
+
|
|
269
|
+
} catch (e) {
|
|
270
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
271
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Save changes to CSS ─────────────────────────────────────────────────────
|
|
65
278
|
if (req.url === '/draply-save' && req.method === 'POST') {
|
|
66
279
|
let body = '';
|
|
67
280
|
req.on('data', c => body += c);
|
|
@@ -71,9 +284,7 @@ const server = http.createServer((req, res) => {
|
|
|
71
284
|
const lines = [];
|
|
72
285
|
for (const ch of (changes || [])) {
|
|
73
286
|
if (!ch.selector) continue;
|
|
74
|
-
const props = Object.entries(ch.props)
|
|
75
|
-
.map(([k, v]) => ` ${k}: ${v};`)
|
|
76
|
-
.join('\n');
|
|
287
|
+
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
77
288
|
const label = ch.selector.split('>').pop().trim();
|
|
78
289
|
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
79
290
|
}
|
|
@@ -90,7 +301,7 @@ const server = http.createServer((req, res) => {
|
|
|
90
301
|
return;
|
|
91
302
|
}
|
|
92
303
|
|
|
93
|
-
// ──
|
|
304
|
+
// ── Serve CSS ───────────────────────────────────────────────────────────────
|
|
94
305
|
if (req.url.split('?')[0] === '/draply.css') {
|
|
95
306
|
const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
|
|
96
307
|
try {
|
|
@@ -102,7 +313,7 @@ const server = http.createServer((req, res) => {
|
|
|
102
313
|
res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
|
|
103
314
|
res.end(css);
|
|
104
315
|
}
|
|
105
|
-
} catch
|
|
316
|
+
} catch {
|
|
106
317
|
if (isModule) {
|
|
107
318
|
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
108
319
|
res.end('export default {};');
|
|
@@ -114,7 +325,7 @@ const server = http.createServer((req, res) => {
|
|
|
114
325
|
return;
|
|
115
326
|
}
|
|
116
327
|
|
|
117
|
-
// ── Proxy to dev server
|
|
328
|
+
// ── Proxy to dev server ─────────────────────────────────────────────────────
|
|
118
329
|
const opts = {
|
|
119
330
|
hostname: targetHost,
|
|
120
331
|
port: targetPort,
|
package/package.json
CHANGED
package/src/draply-features.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Draply Features
|
|
3
3
|
* - Diff Panel (view changes)
|
|
4
4
|
* - Export Panel (copy CSS / JSX / Tailwind)
|
|
5
|
+
* - AI Apply (apply changes to source via Gemini)
|
|
5
6
|
*/
|
|
6
7
|
(function () {
|
|
7
8
|
if (window.__draply_features__) return;
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
.ps-dp.v { display: flex; }
|
|
70
71
|
|
|
71
72
|
.ps-di {
|
|
72
|
-
background: #
|
|
73
|
+
background: #131313;
|
|
73
74
|
border: 1px solid #1e1e3a;
|
|
74
75
|
border-radius: 5px;
|
|
75
76
|
padding: 6px 8px;
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
|
|
105
106
|
.ps-ef { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 4px; }
|
|
106
107
|
.ps-eb {
|
|
107
|
-
background: #
|
|
108
|
+
background: #131313; border: 1px solid #1e1e3a; color: #8888aa;
|
|
108
109
|
border-radius: 3px; padding: 3px 6px; font-size: 8px; cursor: pointer;
|
|
109
110
|
font-family: 'Space Mono', monospace; transition: all .15s;
|
|
110
111
|
}
|
|
@@ -112,7 +113,7 @@
|
|
|
112
113
|
.ps-eb.active { border-color: #7fff6e; color: #7fff6e; background: rgba(127,255,110,0.05); }
|
|
113
114
|
|
|
114
115
|
.ps-ec {
|
|
115
|
-
background: #
|
|
116
|
+
background: #131313; border: 1px solid #1e1e3a; border-radius: 5px;
|
|
116
117
|
padding: 8px; font-family: 'Space Mono', monospace; font-size: 9px;
|
|
117
118
|
color: #ccccee; white-space: pre-wrap; word-break: break-all;
|
|
118
119
|
max-height: 140px; overflow-y: auto; line-height: 1.5;
|
|
@@ -128,6 +129,98 @@
|
|
|
128
129
|
font-family: 'Space Mono', monospace; transition: all .15s; text-align: center;
|
|
129
130
|
}
|
|
130
131
|
.ps-cp:hover { background: linear-gradient(135deg, #7fff6e33, #7fff6e22); border-color: #7fff6e; }
|
|
132
|
+
|
|
133
|
+
/* ── AI APPLY BUTTON ─────────────────────── */
|
|
134
|
+
.ps-ai-btn {
|
|
135
|
+
background: linear-gradient(135deg, #a855f722, #7c3aed22);
|
|
136
|
+
border: 1px solid #a855f744;
|
|
137
|
+
color: #a855f7;
|
|
138
|
+
border-radius: 5px;
|
|
139
|
+
padding: 8px 12px;
|
|
140
|
+
font-size: 10px;
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
font-family: 'Space Mono', monospace;
|
|
143
|
+
transition: all .2s;
|
|
144
|
+
text-align: center;
|
|
145
|
+
margin-top: 6px;
|
|
146
|
+
width: 100%;
|
|
147
|
+
}
|
|
148
|
+
.ps-ai-btn:hover {
|
|
149
|
+
background: linear-gradient(135deg, #a855f733, #7c3aed33);
|
|
150
|
+
border-color: #a855f7;
|
|
151
|
+
box-shadow: 0 0 12px rgba(168,85,247,0.2);
|
|
152
|
+
}
|
|
153
|
+
.ps-ai-btn:disabled {
|
|
154
|
+
opacity: 0.4;
|
|
155
|
+
cursor: default;
|
|
156
|
+
box-shadow: none;
|
|
157
|
+
}
|
|
158
|
+
.ps-ai-btn.loading {
|
|
159
|
+
animation: ps-pulse 1.5s ease-in-out infinite;
|
|
160
|
+
}
|
|
161
|
+
@keyframes ps-pulse {
|
|
162
|
+
0%, 100% { opacity: 0.6; }
|
|
163
|
+
50% { opacity: 1; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ── SETTINGS PANEL ──────────────────────── */
|
|
167
|
+
.ps-settings {
|
|
168
|
+
display: none;
|
|
169
|
+
flex-direction: column;
|
|
170
|
+
gap: 6px;
|
|
171
|
+
padding: 8px 0;
|
|
172
|
+
}
|
|
173
|
+
.ps-settings.v { display: flex; }
|
|
174
|
+
.ps-settings-row {
|
|
175
|
+
display: flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: 6px;
|
|
178
|
+
}
|
|
179
|
+
.ps-settings-label {
|
|
180
|
+
color: #8888aa;
|
|
181
|
+
font-size: 8px;
|
|
182
|
+
text-transform: uppercase;
|
|
183
|
+
letter-spacing: 0.5px;
|
|
184
|
+
margin-bottom: 2px;
|
|
185
|
+
}
|
|
186
|
+
.ps-settings-input {
|
|
187
|
+
flex: 1;
|
|
188
|
+
background: #131313;
|
|
189
|
+
border: 1px solid #1e1e3a;
|
|
190
|
+
color: #ccccee;
|
|
191
|
+
border-radius: 4px;
|
|
192
|
+
padding: 5px 8px;
|
|
193
|
+
font-family: 'Space Mono', monospace;
|
|
194
|
+
font-size: 9px;
|
|
195
|
+
outline: none;
|
|
196
|
+
}
|
|
197
|
+
.ps-settings-input:focus { border-color: #a855f7; }
|
|
198
|
+
.ps-settings-save {
|
|
199
|
+
background: #a855f722;
|
|
200
|
+
border: 1px solid #a855f744;
|
|
201
|
+
color: #a855f7;
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
padding: 4px 10px;
|
|
204
|
+
font-size: 9px;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
font-family: 'Space Mono', monospace;
|
|
207
|
+
}
|
|
208
|
+
.ps-settings-save:hover { border-color: #a855f7; }
|
|
209
|
+
.ps-settings-status {
|
|
210
|
+
font-size: 8px;
|
|
211
|
+
color: #555577;
|
|
212
|
+
}
|
|
213
|
+
.ps-settings-status.ok { color: #7fff6e; }
|
|
214
|
+
.ps-gear {
|
|
215
|
+
background: none;
|
|
216
|
+
border: none;
|
|
217
|
+
color: #555577;
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
padding: 2px;
|
|
221
|
+
transition: color .2s;
|
|
222
|
+
}
|
|
223
|
+
.ps-gear:hover { color: #a855f7; }
|
|
131
224
|
`;
|
|
132
225
|
document.head.appendChild(s);
|
|
133
226
|
|
|
@@ -143,10 +236,15 @@
|
|
|
143
236
|
<div class="ps-ftabs">
|
|
144
237
|
<button class="ps-ftab active" data-ft="diff">📊 Diff</button>
|
|
145
238
|
<button class="ps-ftab" data-ft="export">📦 Export</button>
|
|
239
|
+
<button class="ps-gear" id="__ps_gear__" title="AI Settings">⚙</button>
|
|
146
240
|
</div>
|
|
241
|
+
|
|
242
|
+
<!-- DIFF -->
|
|
147
243
|
<div class="ps-dp v" id="__ps_dp__">
|
|
148
244
|
<div class="ps-de">No changes yet</div>
|
|
149
245
|
</div>
|
|
246
|
+
|
|
247
|
+
<!-- EXPORT -->
|
|
150
248
|
<div class="ps-ep" id="__ps_ep__">
|
|
151
249
|
<div class="ps-ef">
|
|
152
250
|
<button class="ps-eb active" data-fmt="css">CSS</button>
|
|
@@ -156,9 +254,23 @@
|
|
|
156
254
|
<div class="ps-ec" id="__ps_ec__">/* No changes */</div>
|
|
157
255
|
<button class="ps-cp" id="__ps_copy__">📋 Copy</button>
|
|
158
256
|
</div>
|
|
257
|
+
|
|
258
|
+
<!-- SETTINGS -->
|
|
259
|
+
<div class="ps-settings" id="__ps_settings__">
|
|
260
|
+
<div class="ps-settings-label">Gemini API Key</div>
|
|
261
|
+
<div class="ps-settings-row">
|
|
262
|
+
<input class="ps-settings-input" id="__ps_apikey__" type="password" placeholder="AIza...">
|
|
263
|
+
<button class="ps-settings-save" id="__ps_keysave__">Save</button>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="ps-settings-status" id="__ps_keystatus__">
|
|
266
|
+
Get free key at <a href="https://aistudio.google.com/apikey" target="_blank" style="color:#a855f7">aistudio.google.com</a>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- AI APPLY -->
|
|
271
|
+
<button class="ps-ai-btn" id="__ps_ai__" disabled>🤖 AI Apply to Source Code</button>
|
|
159
272
|
`;
|
|
160
273
|
|
|
161
|
-
// Insert before footer
|
|
162
274
|
const foot = sidebar.querySelector('.ps-foot');
|
|
163
275
|
if (foot) sidebar.insertBefore(feat, foot);
|
|
164
276
|
else sidebar.appendChild(feat);
|
|
@@ -168,6 +280,7 @@
|
|
|
168
280
|
// ══════════════════════════════════════════
|
|
169
281
|
const dp = document.getElementById('__ps_dp__');
|
|
170
282
|
const ep = document.getElementById('__ps_ep__');
|
|
283
|
+
const settingsPanel = document.getElementById('__ps_settings__');
|
|
171
284
|
const tabs = feat.querySelectorAll('.ps-ftab');
|
|
172
285
|
|
|
173
286
|
tabs.forEach(tab => {
|
|
@@ -177,11 +290,140 @@
|
|
|
177
290
|
const w = tab.dataset.ft;
|
|
178
291
|
dp.classList.toggle('v', w === 'diff');
|
|
179
292
|
ep.classList.toggle('v', w === 'export');
|
|
293
|
+
settingsPanel.classList.remove('v');
|
|
180
294
|
if (w === 'diff') refreshDiff();
|
|
181
295
|
if (w === 'export') refreshExport();
|
|
182
296
|
};
|
|
183
297
|
});
|
|
184
298
|
|
|
299
|
+
// Gear button
|
|
300
|
+
document.getElementById('__ps_gear__').onclick = () => {
|
|
301
|
+
const vis = settingsPanel.classList.contains('v');
|
|
302
|
+
dp.classList.remove('v');
|
|
303
|
+
ep.classList.remove('v');
|
|
304
|
+
settingsPanel.classList.toggle('v', !vis);
|
|
305
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
306
|
+
if (!vis) checkApiKey();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// ══════════════════════════════════════════
|
|
310
|
+
// API KEY SETTINGS
|
|
311
|
+
// ══════════════════════════════════════════
|
|
312
|
+
const apiKeyInput = document.getElementById('__ps_apikey__');
|
|
313
|
+
const keySaveBtn = document.getElementById('__ps_keysave__');
|
|
314
|
+
const keyStatus = document.getElementById('__ps_keystatus__');
|
|
315
|
+
const aiBtn = document.getElementById('__ps_ai__');
|
|
316
|
+
|
|
317
|
+
function checkApiKey() {
|
|
318
|
+
fetch('/draply-config').then(r => r.json()).then(d => {
|
|
319
|
+
if (d.hasKey) {
|
|
320
|
+
keyStatus.textContent = '✅ API key configured';
|
|
321
|
+
keyStatus.className = 'ps-settings-status ok';
|
|
322
|
+
aiBtn.disabled = false;
|
|
323
|
+
} else {
|
|
324
|
+
aiBtn.disabled = true;
|
|
325
|
+
}
|
|
326
|
+
}).catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
checkApiKey();
|
|
329
|
+
|
|
330
|
+
keySaveBtn.onclick = () => {
|
|
331
|
+
const key = apiKeyInput.value.trim();
|
|
332
|
+
if (!key) return;
|
|
333
|
+
fetch('/draply-config', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ apiKey: key, provider: 'gemini' })
|
|
337
|
+
}).then(r => r.json()).then(d => {
|
|
338
|
+
if (d.ok) {
|
|
339
|
+
keyStatus.textContent = '✅ API key saved!';
|
|
340
|
+
keyStatus.className = 'ps-settings-status ok';
|
|
341
|
+
apiKeyInput.value = '';
|
|
342
|
+
aiBtn.disabled = false;
|
|
343
|
+
showToast('🔑 API key saved');
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// ══════════════════════════════════════════
|
|
349
|
+
// AI APPLY BUTTON
|
|
350
|
+
// ══════════════════════════════════════════
|
|
351
|
+
aiBtn.onclick = () => {
|
|
352
|
+
// Get current changes from overlay's state
|
|
353
|
+
const historyRows = document.querySelectorAll('#__ps__ [data-hid]');
|
|
354
|
+
if (historyRows.length === 0) {
|
|
355
|
+
showToast('No changes to apply');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Collect changes same way overlay does
|
|
360
|
+
const changesMap = new Map();
|
|
361
|
+
historyRows.forEach(btn => {
|
|
362
|
+
const row = btn.closest('div[style]');
|
|
363
|
+
if (!row) return;
|
|
364
|
+
const selDiv = row.querySelector('div[style*="color:#7fff6e"]');
|
|
365
|
+
const propDiv = row.querySelector('div[style*="color:#555577"]');
|
|
366
|
+
if (!selDiv || !propDiv) return;
|
|
367
|
+
const sel = selDiv.textContent.trim();
|
|
368
|
+
if (!changesMap.has(sel)) changesMap.set(sel, {});
|
|
369
|
+
const obj = changesMap.get(sel);
|
|
370
|
+
propDiv.textContent.split(',').forEach(pair => {
|
|
371
|
+
const ci = pair.indexOf(':');
|
|
372
|
+
if (ci < 0) return;
|
|
373
|
+
obj[pair.substring(0, ci).trim()] = pair.substring(ci + 1).trim();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const changes = [];
|
|
378
|
+
changesMap.forEach((props, selector) => {
|
|
379
|
+
changes.push({ selector, props });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (changes.length === 0) {
|
|
383
|
+
showToast('No changes to apply');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Send to AI
|
|
388
|
+
aiBtn.disabled = true;
|
|
389
|
+
aiBtn.classList.add('loading');
|
|
390
|
+
aiBtn.textContent = '🤖 AI is working...';
|
|
391
|
+
showToast('🤖 Sending to AI...');
|
|
392
|
+
|
|
393
|
+
fetch('/draply-ai-apply', {
|
|
394
|
+
method: 'POST',
|
|
395
|
+
headers: { 'Content-Type': 'application/json' },
|
|
396
|
+
body: JSON.stringify({ changes })
|
|
397
|
+
}).then(r => r.json()).then(d => {
|
|
398
|
+
aiBtn.classList.remove('loading');
|
|
399
|
+
aiBtn.textContent = '🤖 AI Apply to Source Code';
|
|
400
|
+
|
|
401
|
+
if (d.ok && d.applied > 0) {
|
|
402
|
+
showToast(`✅ AI applied ${d.applied}/${d.total} changes!`);
|
|
403
|
+
aiBtn.disabled = false;
|
|
404
|
+
// Log results
|
|
405
|
+
(d.results || []).forEach(r => {
|
|
406
|
+
if (r.ok) console.log(`[Draply AI] ✓ ${r.file}`);
|
|
407
|
+
else console.log(`[Draply AI] ✗ ${r.selector}: ${r.reason}`);
|
|
408
|
+
});
|
|
409
|
+
} else if (d.error) {
|
|
410
|
+
showToast('⚠ ' + d.error);
|
|
411
|
+
aiBtn.disabled = false;
|
|
412
|
+
} else {
|
|
413
|
+
showToast('⚠ AI could not apply changes');
|
|
414
|
+
aiBtn.disabled = false;
|
|
415
|
+
(d.results || []).forEach(r => {
|
|
416
|
+
if (!r.ok) console.log(`[Draply AI] ✗ ${r.selector}: ${r.reason}`);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}).catch(err => {
|
|
420
|
+
aiBtn.classList.remove('loading');
|
|
421
|
+
aiBtn.textContent = '🤖 AI Apply to Source Code';
|
|
422
|
+
aiBtn.disabled = false;
|
|
423
|
+
showToast('⚠ ' + err.message);
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
|
|
185
427
|
// ══════════════════════════════════════════
|
|
186
428
|
// DIFF PANEL
|
|
187
429
|
// ══════════════════════════════════════════
|
|
@@ -191,6 +433,8 @@
|
|
|
191
433
|
if (rows.length !== lastLen) {
|
|
192
434
|
lastLen = rows.length;
|
|
193
435
|
refreshDiff();
|
|
436
|
+
// Enable/disable AI button based on changes
|
|
437
|
+
aiBtn.disabled = rows.length === 0;
|
|
194
438
|
}
|
|
195
439
|
}, 400);
|
|
196
440
|
|
|
@@ -346,7 +590,9 @@
|
|
|
346
590
|
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
|
347
591
|
};
|
|
348
592
|
|
|
349
|
-
//
|
|
593
|
+
// ══════════════════════════════════════════
|
|
594
|
+
// HELPERS
|
|
595
|
+
// ══════════════════════════════════════════
|
|
350
596
|
function showToast(msg) {
|
|
351
597
|
const t = document.getElementById('__ps_tst__');
|
|
352
598
|
if (t) { t.textContent = msg; t.classList.add('v'); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('v'), 2800); }
|