web-agent-bridge 1.0.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.
@@ -0,0 +1,566 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Dashboard — Web Agent Bridge</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/css/styles.css">
11
+ </head>
12
+ <body>
13
+ <div class="dashboard">
14
+
15
+ <!-- ═══════════ SIDEBAR ═══════════ -->
16
+ <aside class="sidebar">
17
+ <div class="sidebar-brand">
18
+ <a href="/" class="navbar-brand">
19
+ <div class="brand-icon">⚡</div>
20
+ <span>WAB</span>
21
+ </a>
22
+ </div>
23
+ <nav class="sidebar-nav">
24
+ <a href="#" class="active" data-view="overview">📊 Overview</a>
25
+ <a href="#" data-view="sites">🌐 My Sites</a>
26
+ <a href="#" data-view="analytics">📈 Analytics</a>
27
+ <a href="#" data-view="settings">⚙️ Settings</a>
28
+ <a href="/docs" style="margin-top:20px;">📖 Documentation</a>
29
+ </nav>
30
+ <div class="sidebar-footer">
31
+ <div style="font-size:0.85rem; color:var(--text-muted); margin-bottom:8px;" id="userName"></div>
32
+ <button class="btn btn-ghost btn-sm" onclick="logout()" style="width:100%; justify-content:flex-start;">🚪 Sign Out</button>
33
+ </div>
34
+ </aside>
35
+
36
+ <!-- ═══════════ MAIN CONTENT ═══════════ -->
37
+ <main class="main-content">
38
+
39
+ <!-- ── Overview View ── -->
40
+ <div id="view-overview" class="view active">
41
+ <div class="page-header">
42
+ <h1>Dashboard</h1>
43
+ <button class="btn btn-primary btn-sm" onclick="showAddSiteModal()">+ Add Site</button>
44
+ </div>
45
+
46
+ <div class="stats-grid">
47
+ <div class="stat-card">
48
+ <div class="label">Total Sites</div>
49
+ <div class="value" id="statSites">0</div>
50
+ </div>
51
+ <div class="stat-card">
52
+ <div class="label">Total Actions</div>
53
+ <div class="value" id="statActions">—</div>
54
+ </div>
55
+ <div class="stat-card">
56
+ <div class="label">Active Tier</div>
57
+ <div class="value" id="statTier" style="font-size:1.5rem;">Free</div>
58
+ </div>
59
+ <div class="stat-card">
60
+ <div class="label">API Calls (30d)</div>
61
+ <div class="value" id="statCalls">0</div>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="table-wrapper">
66
+ <div class="table-header">
67
+ <h3 style="font-size:1rem;">Your Sites</h3>
68
+ </div>
69
+ <table>
70
+ <thead>
71
+ <tr>
72
+ <th>Name</th>
73
+ <th>Domain</th>
74
+ <th>Tier</th>
75
+ <th>License Key</th>
76
+ <th>Actions</th>
77
+ </tr>
78
+ </thead>
79
+ <tbody id="sitesTableBody">
80
+ <tr><td colspan="5" style="text-align:center; padding:40px; color:var(--text-muted);">No sites yet. Click "Add Site" to get started.</td></tr>
81
+ </tbody>
82
+ </table>
83
+ </div>
84
+ </div>
85
+
86
+ <!-- ── Sites View ── -->
87
+ <div id="view-sites" class="view">
88
+ <div class="page-header">
89
+ <h1>My Sites</h1>
90
+ <button class="btn btn-primary btn-sm" onclick="showAddSiteModal()">+ Add Site</button>
91
+ </div>
92
+ <div id="sitesGrid" class="grid-2"></div>
93
+ </div>
94
+
95
+ <!-- ── Analytics View ── -->
96
+ <div id="view-analytics" class="view">
97
+ <div class="page-header">
98
+ <h1>Analytics</h1>
99
+ <select class="form-input" style="width:auto;" id="analyticsSiteSelect" onchange="loadAnalytics()">
100
+ <option value="">Select a site</option>
101
+ </select>
102
+ </div>
103
+ <div id="analyticsContent">
104
+ <div style="text-align:center; padding:80px 0; color:var(--text-muted);">
105
+ Select a site to view analytics data.
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ <!-- ── Settings View ── -->
111
+ <div id="view-settings" class="view">
112
+ <div class="page-header">
113
+ <h1>Account Settings</h1>
114
+ </div>
115
+ <div class="card" style="max-width:600px;">
116
+ <h3 style="margin-bottom:20px;">Profile</h3>
117
+ <div class="form-group">
118
+ <label>Name</label>
119
+ <input type="text" class="form-input" id="settingsName" readonly>
120
+ </div>
121
+ <div class="form-group">
122
+ <label>Email</label>
123
+ <input type="email" class="form-input" id="settingsEmail" readonly>
124
+ </div>
125
+ <div class="form-group">
126
+ <label>Company</label>
127
+ <input type="text" class="form-input" id="settingsCompany" readonly>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ </main>
133
+ </div>
134
+
135
+ <!-- ═══════════ ADD SITE MODAL ═══════════ -->
136
+ <div class="modal-overlay" id="addSiteModal">
137
+ <div class="modal">
138
+ <div class="modal-header">
139
+ <h2>Add New Site</h2>
140
+ <button class="modal-close" onclick="closeModal('addSiteModal')">&times;</button>
141
+ </div>
142
+ <div class="modal-body">
143
+ <div class="alert alert-error" id="addSiteError"></div>
144
+ <form id="addSiteForm">
145
+ <div class="form-group">
146
+ <label for="siteName">Site Name</label>
147
+ <input type="text" id="siteName" class="form-input" placeholder="My Website" required>
148
+ </div>
149
+ <div class="form-group">
150
+ <label for="siteDomain">Domain</label>
151
+ <input type="text" id="siteDomain" class="form-input" placeholder="example.com" required>
152
+ </div>
153
+ <div class="form-group">
154
+ <label for="siteDescription">Description</label>
155
+ <textarea id="siteDescription" class="form-input" placeholder="Brief description of your site"></textarea>
156
+ </div>
157
+ </form>
158
+ </div>
159
+ <div class="modal-footer">
160
+ <button class="btn btn-secondary" onclick="closeModal('addSiteModal')">Cancel</button>
161
+ <button class="btn btn-primary" onclick="addSite()">Add Site</button>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- ═══════════ SITE DETAIL MODAL ═══════════ -->
167
+ <div class="modal-overlay" id="siteDetailModal">
168
+ <div class="modal" style="max-width:700px;">
169
+ <div class="modal-header">
170
+ <h2 id="detailSiteName">Site Details</h2>
171
+ <button class="modal-close" onclick="closeModal('siteDetailModal')">&times;</button>
172
+ </div>
173
+ <div class="modal-body">
174
+ <div class="tabs">
175
+ <button class="tab active" onclick="switchDetailTab('snippet')">Install Snippet</button>
176
+ <button class="tab" onclick="switchDetailTab('config')">Configuration</button>
177
+ <button class="tab" onclick="switchDetailTab('info')">Info</button>
178
+ </div>
179
+
180
+ <div class="tab-content active" id="tab-snippet">
181
+ <p style="color:var(--text-secondary); margin-bottom:16px;">Add this code to your website's <code>&lt;head&gt;</code> tag:</p>
182
+ <div class="snippet-box">
183
+ <button class="copy-btn" onclick="copySnippet()">Copy</button>
184
+ <pre id="snippetCode"></pre>
185
+ </div>
186
+ </div>
187
+
188
+ <div class="tab-content" id="tab-config">
189
+ <h4 style="margin-bottom:16px;">Agent Permissions</h4>
190
+ <div id="configToggles"></div>
191
+ <div style="margin-top:20px;">
192
+ <button class="btn btn-primary btn-sm" onclick="saveConfig()">Save Changes</button>
193
+ </div>
194
+ </div>
195
+
196
+ <div class="tab-content" id="tab-info">
197
+ <div class="form-group">
198
+ <label>License Key</label>
199
+ <div class="snippet-box"><pre id="detailLicense"></pre></div>
200
+ </div>
201
+ <div class="form-group">
202
+ <label>API Key</label>
203
+ <div class="snippet-box"><pre id="detailApiKey"></pre></div>
204
+ </div>
205
+ <div class="form-group">
206
+ <label>Tier</label>
207
+ <div id="detailTier"></div>
208
+ </div>
209
+ <div class="form-group">
210
+ <label>Created</label>
211
+ <div id="detailCreated" style="color:var(--text-secondary);"></div>
212
+ </div>
213
+ <div style="margin-top:24px; padding-top:20px; border-top:1px solid var(--border-color);">
214
+ <button class="btn btn-danger btn-sm" onclick="deleteSiteConfirm()">Delete Site</button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </div>
220
+
221
+ <script>
222
+ // ─── State ──────────────────────────────────────────────────────────
223
+ const API = '/api';
224
+ let token = localStorage.getItem('wab_token');
225
+ let user = JSON.parse(localStorage.getItem('wab_user') || 'null');
226
+ let sites = [];
227
+ let currentSite = null;
228
+ let currentConfig = null;
229
+
230
+ if (!token) window.location.href = '/login';
231
+
232
+ function headers() {
233
+ return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` };
234
+ }
235
+
236
+ function logout() {
237
+ localStorage.removeItem('wab_token');
238
+ localStorage.removeItem('wab_user');
239
+ window.location.href = '/login';
240
+ }
241
+
242
+ // ─── Navigation ─────────────────────────────────────────────────────
243
+ document.querySelectorAll('.sidebar-nav a[data-view]').forEach(link => {
244
+ link.addEventListener('click', (e) => {
245
+ e.preventDefault();
246
+ const view = link.dataset.view;
247
+ document.querySelectorAll('.sidebar-nav a').forEach(a => a.classList.remove('active'));
248
+ link.classList.add('active');
249
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
250
+ const el = document.getElementById(`view-${view}`);
251
+ if (el) el.classList.add('active');
252
+ });
253
+ });
254
+
255
+ // ─── Init ───────────────────────────────────────────────────────────
256
+ async function init() {
257
+ if (user) {
258
+ document.getElementById('userName').textContent = user.name || user.email;
259
+ document.getElementById('settingsName').value = user.name || '';
260
+ document.getElementById('settingsEmail').value = user.email || '';
261
+ document.getElementById('settingsCompany').value = user.company || '';
262
+ }
263
+ await loadSites();
264
+ }
265
+
266
+ // ─── Load Sites ─────────────────────────────────────────────────────
267
+ async function loadSites() {
268
+ try {
269
+ const res = await fetch(`${API}/sites`, { headers: headers() });
270
+ if (res.status === 401 || res.status === 403) return logout();
271
+ const data = await res.json();
272
+ sites = data.sites || [];
273
+ renderOverview();
274
+ renderSitesGrid();
275
+ renderAnalyticsSelect();
276
+ } catch (err) {
277
+ console.error('Failed to load sites:', err);
278
+ }
279
+ }
280
+
281
+ function renderOverview() {
282
+ document.getElementById('statSites').textContent = sites.length;
283
+ const topTier = sites.reduce((best, s) => {
284
+ const order = { enterprise: 4, pro: 3, starter: 2, free: 1 };
285
+ return (order[s.tier] || 0) > (order[best] || 0) ? s.tier : best;
286
+ }, 'free');
287
+ document.getElementById('statTier').textContent = topTier.charAt(0).toUpperCase() + topTier.slice(1);
288
+
289
+ const tbody = document.getElementById('sitesTableBody');
290
+ if (sites.length === 0) {
291
+ tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; padding:40px; color:var(--text-muted);">No sites yet. Click "Add Site" to get started.</td></tr>';
292
+ return;
293
+ }
294
+
295
+ tbody.innerHTML = sites.map(s => `
296
+ <tr>
297
+ <td><strong style="color:var(--text-primary);">${esc(s.name)}</strong></td>
298
+ <td><span class="mono" style="font-size:0.85rem;">${esc(s.domain)}</span></td>
299
+ <td><span class="badge badge-${s.tier}">${s.tier}</span></td>
300
+ <td><span class="mono" style="font-size:0.8rem; color:var(--text-muted);">${esc(s.license_key)}</span></td>
301
+ <td>
302
+ <button class="btn btn-secondary btn-sm" onclick="openSiteDetail('${s.id}')">Manage</button>
303
+ </td>
304
+ </tr>
305
+ `).join('');
306
+ }
307
+
308
+ function renderSitesGrid() {
309
+ const grid = document.getElementById('sitesGrid');
310
+ if (sites.length === 0) {
311
+ grid.innerHTML = '<div style="grid-column:1/-1; text-align:center; padding:60px; color:var(--text-muted);">No sites yet.</div>';
312
+ return;
313
+ }
314
+
315
+ grid.innerHTML = sites.map(s => `
316
+ <div class="card" style="cursor:pointer;" onclick="openSiteDetail('${s.id}')">
317
+ <div style="display:flex; justify-content:space-between; align-items:start; margin-bottom:16px;">
318
+ <h3 style="font-size:1.1rem;">${esc(s.name)}</h3>
319
+ <span class="badge badge-${s.tier}">${s.tier}</span>
320
+ </div>
321
+ <p style="color:var(--text-muted); font-size:0.85rem; font-family:var(--font-mono);">${esc(s.domain)}</p>
322
+ ${s.description ? `<p style="margin-top:8px; font-size:0.9rem;">${esc(s.description)}</p>` : ''}
323
+ <div style="margin-top:16px; padding-top:12px; border-top:1px solid var(--border-color); display:flex; justify-content:space-between;">
324
+ <span style="font-size:0.8rem; color:var(--text-muted);">Key: ${esc(s.license_key)}</span>
325
+ <span class="badge badge-active">Active</span>
326
+ </div>
327
+ </div>
328
+ `).join('');
329
+ }
330
+
331
+ function renderAnalyticsSelect() {
332
+ const sel = document.getElementById('analyticsSiteSelect');
333
+ sel.innerHTML = '<option value="">Select a site</option>' +
334
+ sites.map(s => `<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
335
+ }
336
+
337
+ // ─── Add Site ───────────────────────────────────────────────────────
338
+ function showAddSiteModal() { openModal('addSiteModal'); }
339
+
340
+ async function addSite() {
341
+ const name = document.getElementById('siteName').value.trim();
342
+ const domain = document.getElementById('siteDomain').value.trim();
343
+ const description = document.getElementById('siteDescription').value.trim();
344
+ const errEl = document.getElementById('addSiteError');
345
+ errEl.style.display = 'none';
346
+
347
+ if (!name || !domain) {
348
+ errEl.textContent = 'Name and domain are required';
349
+ errEl.style.display = 'block';
350
+ return;
351
+ }
352
+
353
+ try {
354
+ const res = await fetch(`${API}/sites`, {
355
+ method: 'POST',
356
+ headers: headers(),
357
+ body: JSON.stringify({ name, domain, description })
358
+ });
359
+ const data = await res.json();
360
+
361
+ if (!res.ok) {
362
+ errEl.textContent = data.error || 'Failed to add site';
363
+ errEl.style.display = 'block';
364
+ return;
365
+ }
366
+
367
+ closeModal('addSiteModal');
368
+ document.getElementById('addSiteForm').reset();
369
+ await loadSites();
370
+ openSiteDetail(data.site.id);
371
+ } catch (err) {
372
+ errEl.textContent = 'Connection error';
373
+ errEl.style.display = 'block';
374
+ }
375
+ }
376
+
377
+ // ─── Site Detail ────────────────────────────────────────────────────
378
+ async function openSiteDetail(siteId) {
379
+ try {
380
+ const res = await fetch(`${API}/sites/${siteId}`, { headers: headers() });
381
+ const data = await res.json();
382
+ currentSite = data.site;
383
+ currentConfig = currentSite.config || {};
384
+
385
+ document.getElementById('detailSiteName').textContent = currentSite.name;
386
+ document.getElementById('detailLicense').textContent = currentSite.license_key;
387
+ document.getElementById('detailApiKey').textContent = currentSite.api_key || '—';
388
+ document.getElementById('detailTier').innerHTML = `<span class="badge badge-${currentSite.tier}">${currentSite.tier}</span>`;
389
+ document.getElementById('detailCreated').textContent = new Date(currentSite.created_at).toLocaleDateString();
390
+
391
+ const snippetRes = await fetch(`${API}/sites/${siteId}/snippet`, { headers: headers() });
392
+ const snippetData = await snippetRes.json();
393
+ document.getElementById('snippetCode').textContent = snippetData.snippet;
394
+
395
+ renderConfigToggles();
396
+ switchDetailTab('snippet');
397
+ openModal('siteDetailModal');
398
+ } catch (err) {
399
+ console.error('Failed to load site:', err);
400
+ }
401
+ }
402
+
403
+ function renderConfigToggles() {
404
+ const perms = currentConfig.agentPermissions || {};
405
+ const container = document.getElementById('configToggles');
406
+
407
+ const permLabels = {
408
+ readContent: { label: 'Read Content', desc: 'Allow agents to read page text' },
409
+ click: { label: 'Click Elements', desc: 'Allow agents to click buttons and links' },
410
+ fillForms: { label: 'Fill Forms', desc: 'Allow agents to fill and submit forms' },
411
+ scroll: { label: 'Scroll', desc: 'Allow agents to scroll the page' },
412
+ navigate: { label: 'Navigate', desc: 'Allow agents to navigate between pages' },
413
+ apiAccess: { label: 'API Access', desc: 'Allow agents to call internal APIs (Pro+)' },
414
+ automatedLogin: { label: 'Automated Login', desc: 'Allow agents to perform login (Starter+)' },
415
+ extractData: { label: 'Extract Data', desc: 'Allow agents to extract structured data (Pro+)' }
416
+ };
417
+
418
+ container.innerHTML = Object.entries(permLabels).map(([key, info]) => `
419
+ <div class="toggle-wrap">
420
+ <div class="toggle-label">
421
+ ${info.label}
422
+ <small>${info.desc}</small>
423
+ </div>
424
+ <label class="toggle">
425
+ <input type="checkbox" data-perm="${key}" ${perms[key] ? 'checked' : ''}>
426
+ <span class="toggle-slider"></span>
427
+ </label>
428
+ </div>
429
+ `).join('');
430
+ }
431
+
432
+ async function saveConfig() {
433
+ const toggles = document.querySelectorAll('#configToggles input[data-perm]');
434
+ const agentPermissions = {};
435
+ toggles.forEach(t => { agentPermissions[t.dataset.perm] = t.checked; });
436
+
437
+ currentConfig.agentPermissions = agentPermissions;
438
+
439
+ try {
440
+ await fetch(`${API}/sites/${currentSite.id}/config`, {
441
+ method: 'PUT',
442
+ headers: headers(),
443
+ body: JSON.stringify({ config: currentConfig })
444
+ });
445
+ await loadSites();
446
+ alert('Configuration saved!');
447
+ } catch (err) {
448
+ alert('Failed to save configuration');
449
+ }
450
+ }
451
+
452
+ async function deleteSiteConfirm() {
453
+ if (!confirm(`Are you sure you want to delete "${currentSite.name}"? This cannot be undone.`)) return;
454
+
455
+ try {
456
+ await fetch(`${API}/sites/${currentSite.id}`, { method: 'DELETE', headers: headers() });
457
+ closeModal('siteDetailModal');
458
+ await loadSites();
459
+ } catch (err) {
460
+ alert('Failed to delete site');
461
+ }
462
+ }
463
+
464
+ function switchDetailTab(tab) {
465
+ document.querySelectorAll('#siteDetailModal .tab').forEach(t => t.classList.remove('active'));
466
+ document.querySelectorAll('#siteDetailModal .tab-content').forEach(c => c.classList.remove('active'));
467
+ document.querySelector(`#siteDetailModal .tab[onclick*="${tab}"]`).classList.add('active');
468
+ document.getElementById(`tab-${tab}`).classList.add('active');
469
+ }
470
+
471
+ function copySnippet() {
472
+ const text = document.getElementById('snippetCode').textContent;
473
+ navigator.clipboard.writeText(text).then(() => {
474
+ const btn = document.querySelector('#tab-snippet .copy-btn');
475
+ btn.textContent = 'Copied!';
476
+ setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
477
+ });
478
+ }
479
+
480
+ // ─── Analytics ──────────────────────────────────────────────────────
481
+ async function loadAnalytics() {
482
+ const siteId = document.getElementById('analyticsSiteSelect').value;
483
+ const container = document.getElementById('analyticsContent');
484
+
485
+ if (!siteId) {
486
+ container.innerHTML = '<div style="text-align:center; padding:80px 0; color:var(--text-muted);">Select a site to view analytics data.</div>';
487
+ return;
488
+ }
489
+
490
+ try {
491
+ const res = await fetch(`${API}/sites/${siteId}/analytics?days=30`, { headers: headers() });
492
+ const data = await res.json();
493
+
494
+ if (!data.summary || data.summary.length === 0) {
495
+ container.innerHTML = `
496
+ <div style="text-align:center; padding:60px 0; color:var(--text-muted);">
497
+ <div style="font-size:2.5rem; margin-bottom:16px;">📊</div>
498
+ <p>No analytics data yet.</p>
499
+ <p style="font-size:0.85rem; margin-top:8px;">Analytics will appear once AI agents start interacting with your site.</p>
500
+ </div>`;
501
+ return;
502
+ }
503
+
504
+ const totalCalls = data.summary.reduce((s, a) => s + a.count, 0);
505
+ const totalSuccess = data.summary.reduce((s, a) => s + a.successes, 0);
506
+ const rate = totalCalls > 0 ? Math.round((totalSuccess / totalCalls) * 100) : 0;
507
+
508
+ container.innerHTML = `
509
+ <div class="stats-grid" style="grid-template-columns:repeat(3,1fr); margin-bottom:24px;">
510
+ <div class="stat-card">
511
+ <div class="label">Total Calls</div>
512
+ <div class="value">${totalCalls}</div>
513
+ </div>
514
+ <div class="stat-card">
515
+ <div class="label">Success Rate</div>
516
+ <div class="value">${rate}%</div>
517
+ </div>
518
+ <div class="stat-card">
519
+ <div class="label">Unique Actions</div>
520
+ <div class="value">${data.summary.length}</div>
521
+ </div>
522
+ </div>
523
+ <div class="table-wrapper">
524
+ <div class="table-header"><h3 style="font-size:1rem;">Action Breakdown</h3></div>
525
+ <table>
526
+ <thead><tr><th>Action</th><th>Type</th><th>Calls</th><th>Successes</th><th>Rate</th></tr></thead>
527
+ <tbody>
528
+ ${data.summary.map(a => `
529
+ <tr>
530
+ <td><strong style="color:var(--text-primary);">${esc(a.action_name)}</strong></td>
531
+ <td><span class="badge badge-free">${esc(a.trigger_type || '—')}</span></td>
532
+ <td>${a.count}</td>
533
+ <td>${a.successes}</td>
534
+ <td>${a.count > 0 ? Math.round((a.successes / a.count) * 100) : 0}%</td>
535
+ </tr>
536
+ `).join('')}
537
+ </tbody>
538
+ </table>
539
+ </div>`;
540
+ } catch (err) {
541
+ container.innerHTML = '<div style="text-align:center; padding:60px 0; color:var(--accent-red);">Failed to load analytics.</div>';
542
+ }
543
+ }
544
+
545
+ // ─── Modal Helpers ──────────────────────────────────────────────────
546
+ function openModal(id) { document.getElementById(id).classList.add('active'); }
547
+ function closeModal(id) { document.getElementById(id).classList.remove('active'); }
548
+
549
+ // ─── Utils ──────────────────────────────────────────────────────────
550
+ function esc(str) {
551
+ if (!str) return '';
552
+ const d = document.createElement('div');
553
+ d.textContent = str;
554
+ return d.innerHTML;
555
+ }
556
+
557
+ // ─── View toggling style ────────────────────────────────────────────
558
+ const style = document.createElement('style');
559
+ style.textContent = '.view { display: none; } .view.active { display: block; }';
560
+ document.head.appendChild(style);
561
+
562
+ // ─── Start ──────────────────────────────────────────────────────────
563
+ init();
564
+ </script>
565
+ </body>
566
+ </html>