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 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
- // Store CSS in hidden .draply/ folder to keep user's project clean
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
- fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
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
- // ── Draply: Save changes to CSS ─────────────────────────────────────────────
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
- // ── Draply: Serve CSS ──────────────────────────────────────────────────────
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 (e) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draply-dev",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Visual overlay for any frontend project — move, resize, restyle live in the browser, save to CSS",
5
5
  "author": "Arman",
6
6
  "type": "commonjs",
@@ -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: #0d0d1a;
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: #0d0d1a; border: 1px solid #1e1e3a; color: #8888aa;
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: #0d0d1a; border: 1px solid #1e1e3a; border-radius: 5px;
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
- // Toast helper
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); }