draply-dev 1.2.1 → 1.3.1
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 +224 -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,233 @@ 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
|
+
// Group changes by file
|
|
194
|
+
const results = [];
|
|
195
|
+
const fileChanges = new Map(); // file -> { found, allProps }
|
|
196
|
+
|
|
197
|
+
for (const ch of (changes || [])) {
|
|
198
|
+
if (!ch.selector || !ch.props) continue;
|
|
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
|
+
const found = findFileForClass(projectRoot, className);
|
|
205
|
+
if (!found) {
|
|
206
|
+
results.push({ selector: ch.selector, ok: false, reason: `"${className}" not found in source` });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!fileChanges.has(found.file)) {
|
|
210
|
+
fileChanges.set(found.file, { found, items: [] });
|
|
211
|
+
}
|
|
212
|
+
fileChanges.get(found.file).items.push({ selector: ch.selector, className, props: ch.props });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One AI call per file (saves tokens & rate limit)
|
|
216
|
+
for (const [filePath, { found, items }] of fileChanges) {
|
|
217
|
+
console.log(` \x1b[36m🤖\x1b[0m AI applying ${items.length} changes → ${found.relativePath}`);
|
|
218
|
+
|
|
219
|
+
// Build combined prompt
|
|
220
|
+
let changesBlock = '';
|
|
221
|
+
items.forEach(item => {
|
|
222
|
+
const propsStr = Object.entries(item.props).map(([k, v]) => ` ${k}: ${v}`).join('\n');
|
|
223
|
+
changesBlock += `\nElement .${item.className}:\n${propsStr}\n`;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const prompt = `You are a code editor. Modify the file to apply CSS style changes.
|
|
227
|
+
|
|
228
|
+
FILE: ${found.relativePath}
|
|
229
|
+
\`\`\`
|
|
230
|
+
${found.content}
|
|
231
|
+
\`\`\`
|
|
232
|
+
|
|
233
|
+
CHANGES TO APPLY:
|
|
234
|
+
${changesBlock}
|
|
235
|
+
RULES:
|
|
236
|
+
- If CSS/SCSS file: find each class rule and update/add properties. Add rules if missing.
|
|
237
|
+
- If JSX/TSX file: apply in the most appropriate way:
|
|
238
|
+
- Existing inline styles → update them
|
|
239
|
+
- File imports CSS → note which CSS file to edit
|
|
240
|
+
- Tailwind classes → update className with Tailwind classes
|
|
241
|
+
- Otherwise → add style prop
|
|
242
|
+
- Keep ALL existing code intact. Only modify what's needed.
|
|
243
|
+
- Do NOT add extra comments.
|
|
244
|
+
|
|
245
|
+
Return ONLY the complete modified file. No markdown fences, no explanations.`;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const result = await callGemini(cfg.apiKey, prompt);
|
|
249
|
+
let code = result.trim();
|
|
250
|
+
if (code.startsWith('```')) {
|
|
251
|
+
code = code.replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '');
|
|
252
|
+
}
|
|
253
|
+
if (code.length < 20) {
|
|
254
|
+
items.forEach(item => results.push({ selector: item.selector, ok: false, reason: 'AI returned invalid response' }));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
fs.writeFileSync(found.file, code, 'utf8');
|
|
258
|
+
console.log(` \x1b[32m✓\x1b[0m Applied to ${found.relativePath}`);
|
|
259
|
+
items.forEach(item => results.push({ selector: item.selector, ok: true, file: found.relativePath }));
|
|
260
|
+
} catch (aiErr) {
|
|
261
|
+
console.log(` \x1b[31m✗\x1b[0m AI error: ${aiErr.message}`);
|
|
262
|
+
items.forEach(item => results.push({ selector: item.selector, ok: false, reason: aiErr.message }));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const applied = results.filter(r => r.ok).length;
|
|
267
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
268
|
+
res.end(JSON.stringify({ ok: true, applied, total: results.length, results }));
|
|
269
|
+
|
|
270
|
+
} catch (e) {
|
|
271
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
272
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Save changes to CSS ─────────────────────────────────────────────────────
|
|
65
279
|
if (req.url === '/draply-save' && req.method === 'POST') {
|
|
66
280
|
let body = '';
|
|
67
281
|
req.on('data', c => body += c);
|
|
@@ -71,9 +285,7 @@ const server = http.createServer((req, res) => {
|
|
|
71
285
|
const lines = [];
|
|
72
286
|
for (const ch of (changes || [])) {
|
|
73
287
|
if (!ch.selector) continue;
|
|
74
|
-
const props = Object.entries(ch.props)
|
|
75
|
-
.map(([k, v]) => ` ${k}: ${v};`)
|
|
76
|
-
.join('\n');
|
|
288
|
+
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
77
289
|
const label = ch.selector.split('>').pop().trim();
|
|
78
290
|
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
79
291
|
}
|
|
@@ -90,7 +302,7 @@ const server = http.createServer((req, res) => {
|
|
|
90
302
|
return;
|
|
91
303
|
}
|
|
92
304
|
|
|
93
|
-
// ──
|
|
305
|
+
// ── Serve CSS ───────────────────────────────────────────────────────────────
|
|
94
306
|
if (req.url.split('?')[0] === '/draply.css') {
|
|
95
307
|
const isModule = req.headers['sec-fetch-dest'] === 'script' || req.url.includes('import');
|
|
96
308
|
try {
|
|
@@ -102,7 +314,7 @@ const server = http.createServer((req, res) => {
|
|
|
102
314
|
res.writeHead(200, { 'Content-Type': 'text/css', 'cache-control': 'no-store' });
|
|
103
315
|
res.end(css);
|
|
104
316
|
}
|
|
105
|
-
} catch
|
|
317
|
+
} catch {
|
|
106
318
|
if (isModule) {
|
|
107
319
|
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
108
320
|
res.end('export default {};');
|
|
@@ -114,7 +326,7 @@ const server = http.createServer((req, res) => {
|
|
|
114
326
|
return;
|
|
115
327
|
}
|
|
116
328
|
|
|
117
|
-
// ── Proxy to dev server
|
|
329
|
+
// ── Proxy to dev server ─────────────────────────────────────────────────────
|
|
118
330
|
const opts = {
|
|
119
331
|
hostname: targetHost,
|
|
120
332
|
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); }
|