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 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,233 @@ 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
+ // 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
- // ── Draply: Serve CSS ──────────────────────────────────────────────────────
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 (e) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draply-dev",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
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); }