@open-assets/open-assets 0.1.2

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,518 @@
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>Open Assets</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
9
+ <style>
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+ body { background: #0a0a0a; color: #e5e5e5; font-family: -apple-system, system-ui, sans-serif; height: 100vh; overflow: hidden; user-select: none; }
12
+
13
+ .toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: rgba(10,10,10,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.06); }
14
+ .toolbar-left { display: flex; align-items: center; gap: 12px; }
15
+ .toolbar-left h1 { font-size: 14px; font-weight: 700; color: #fff; }
16
+ .segmented { display: flex; background: rgba(255,255,255,0.06); border-radius: 6px; padding: 2px; gap: 1px; }
17
+ .seg-btn { padding: 4px 14px; border-radius: 5px; border: none; background: transparent; color: #888; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s; font-family: inherit; text-decoration: none; }
18
+ .seg-btn:hover { color: #bbb; }
19
+ .seg-btn.active { background: rgba(255,255,255,0.12); color: #fff; }
20
+ .toolbar-right { display: flex; align-items: center; gap: 4px; }
21
+ .toolbar-btn { width: 32px; height: 32px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); color: #ccc; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; line-height: 1; }
22
+ .toolbar-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); color: #fff; }
23
+ .toolbar-btn:active { background: rgba(255,255,255,0.15); }
24
+ .zoom-label { font-size: 11px; color: #888; min-width: 40px; text-align: center; font-variant-numeric: tabular-nums; }
25
+ .toolbar-divider { width: 1px; height: 20px; background: rgba(255,255,255,0.08); margin: 0 4px; }
26
+
27
+ /* Export button */
28
+ .export-wrap { position: relative; }
29
+ .export-btn { padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); color: #ccc; font-size: 12px; font-weight: 600; cursor: pointer; font-family: inherit; transition: background 0.15s; display: flex; align-items: center; gap: 5px; }
30
+ .export-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
31
+
32
+ /* Export dropdown */
33
+ .export-dropdown { display: none; position: absolute; top: 40px; right: 0; background: #1a1a1a; border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; padding: 6px; min-width: 260px; box-shadow: 0 12px 40px rgba(0,0,0,0.6); z-index: 200; max-height: 70vh; overflow-y: auto; }
34
+ .export-dropdown.open { display: block; }
35
+ .export-dropdown .section-label { font-size: 10px; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: 0.08em; padding: 8px 10px 4px; }
36
+ .export-dropdown button { width: 100%; padding: 8px 10px; border: none; background: transparent; color: #ccc; font-size: 13px; font-family: inherit; text-align: left; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
37
+ .export-dropdown button:hover { background: rgba(255,255,255,0.08); color: #fff; }
38
+ .export-dropdown button .dim { font-size: 11px; color: #666; font-variant-numeric: tabular-nums; }
39
+ .export-dropdown button:disabled { opacity: 0.4; cursor: default; }
40
+ .export-dropdown button:disabled:hover { background: transparent; color: #ccc; }
41
+ .export-dropdown hr { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 4px 0; }
42
+ .export-dropdown .custom-row { display: flex; align-items: center; gap: 6px; padding: 6px 10px; }
43
+ .export-dropdown .custom-row input { width: 64px; padding: 4px 8px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.06); color: #fff; font-size: 12px; font-family: inherit; text-align: center; }
44
+ .export-dropdown .custom-row span { color: #555; font-size: 12px; }
45
+ .export-dropdown .custom-row button { width: auto; padding: 4px 12px; background: rgba(255,255,255,0.08); border-radius: 5px; font-size: 12px; }
46
+
47
+ /* Progress toast */
48
+ .toast { display: none; position: fixed; bottom: 48px; left: 50%; transform: translateX(-50%); background: #1a1a1a; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 10px 20px; font-size: 13px; color: #ccc; z-index: 300; box-shadow: 0 8px 30px rgba(0,0,0,0.5); }
49
+ .toast.show { display: block; }
50
+
51
+ .canvas { position: absolute; top: 48px; left: 0; right: 0; bottom: 0; overflow: hidden; cursor: grab; }
52
+ .canvas:active { cursor: grabbing; }
53
+ .canvas-inner { position: absolute; transform-origin: 0 0; display: flex; gap: 24px; padding: 40px; align-items: flex-start; }
54
+ .screenshot-slot { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 10px; }
55
+ .screenshot-label { font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; }
56
+ .screenshot-border { overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.08); }
57
+ .screenshot-border iframe { border: none; display: block; pointer-events: none; }
58
+
59
+ /* SVG/logo container with checkerboard background */
60
+ .logo-container {
61
+ border: 1px solid rgba(255,255,255,0.08);
62
+ border-radius: 24px;
63
+ overflow: hidden;
64
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
65
+ background-image:
66
+ linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
67
+ linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
68
+ linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
69
+ linear-gradient(-45deg, transparent 75%, #1a1a1a 75%);
70
+ background-size: 40px 40px;
71
+ background-position: 0 0, 0 20px, 20px -20px, -20px 0px;
72
+ background-color: #111;
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ }
77
+
78
+ .hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #555; z-index: 100; pointer-events: none; }
79
+ .hint kbd { display: inline-block; padding: 1px 5px; border: 1px solid #444; border-radius: 3px; background: #1a1a1a; font-family: inherit; font-size: 10px; color: #888; }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <div class="toolbar">
84
+ <div class="toolbar-left">
85
+ <h1 id="app-title">Loading...</h1>
86
+ <div class="segmented" id="tab-bar"></div>
87
+ </div>
88
+ <div class="toolbar-right">
89
+ <button class="toolbar-btn" id="zoom-out" title="Zoom out">−</button>
90
+ <span class="zoom-label" id="zoom-label">100%</span>
91
+ <button class="toolbar-btn" id="zoom-in" title="Zoom in">+</button>
92
+ <div class="toolbar-divider"></div>
93
+ <button class="toolbar-btn" id="fit-all" title="Fit all" style="font-size: 13px;">⊞</button>
94
+ <button class="toolbar-btn" id="reset" title="Reset to 100%" style="font-size: 12px;">1:1</button>
95
+ <div class="toolbar-divider"></div>
96
+ <div class="export-wrap">
97
+ <button class="export-btn" id="export-toggle">Export ▾</button>
98
+ <div class="export-dropdown" id="export-dropdown"></div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="canvas" id="canvas">
104
+ <div class="canvas-inner" id="canvas-inner"></div>
105
+ </div>
106
+
107
+ <div class="toast" id="toast"></div>
108
+
109
+ <div class="hint">
110
+ <kbd>Scroll</kbd> zoom &nbsp; <kbd>Click + Drag</kbd> pan &nbsp; <kbd>+</kbd> <kbd>−</kbd> zoom &nbsp; <kbd>0</kbd> fit all &nbsp; <kbd>1</kbd> reset 1:1
111
+ </div>
112
+
113
+ <script>
114
+ // ---- State ----
115
+ let manifest = null;
116
+ let activeCollection = null;
117
+
118
+ // ---- DOM refs ----
119
+ const canvas = document.getElementById('canvas');
120
+ const inner = document.getElementById('canvas-inner');
121
+ const zoomLabel = document.getElementById('zoom-label');
122
+ const tabBar = document.getElementById('tab-bar');
123
+ const exportDropdown = document.getElementById('export-dropdown');
124
+ const exportToggle = document.getElementById('export-toggle');
125
+ const toast = document.getElementById('toast');
126
+
127
+ // ---- Zoom / Pan ----
128
+ let scale = 1, panX = 0, panY = 0, isPanning = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
129
+ const MIN_SCALE = 0.05, MAX_SCALE = 2, ZOOM_STEP = 0.15;
130
+
131
+ function applyTransform() { inner.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`; zoomLabel.textContent = Math.round(scale * 100) + '%'; }
132
+ function zoomTo(s, cx, cy) { s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, s)); const r = s / scale; panX = cx - r * (cx - panX); panY = cy - r * (cy - panY); scale = s; applyTransform(); }
133
+ function fitAll() { const r = canvas.getBoundingClientRect(); const w = inner.scrollWidth, h = inner.scrollHeight; scale = Math.min(r.width / w, r.height / h) * 0.95; scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); panX = (r.width - w * scale) / 2; panY = (r.height - h * scale) / 2; applyTransform(); }
134
+ function resetZoom() { scale = 1; panX = 0; panY = 0; applyTransform(); }
135
+
136
+ canvas.addEventListener('wheel', (e) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); zoomTo(scale + (-Math.sign(e.deltaY) * ZOOM_STEP * scale), e.clientX - r.left, e.clientY - r.top); }, { passive: false });
137
+ canvas.addEventListener('mousedown', (e) => { isPanning = true; startX = e.clientX; startY = e.clientY; startPanX = panX; startPanY = panY; });
138
+ window.addEventListener('mousemove', (e) => { if (!isPanning) return; panX = startPanX + (e.clientX - startX); panY = startPanY + (e.clientY - startY); applyTransform(); });
139
+ window.addEventListener('mouseup', () => { isPanning = false; });
140
+ document.getElementById('zoom-in').addEventListener('click', () => { const r = canvas.getBoundingClientRect(); zoomTo(scale + ZOOM_STEP * scale, r.width / 2, r.height / 2); });
141
+ document.getElementById('zoom-out').addEventListener('click', () => { const r = canvas.getBoundingClientRect(); zoomTo(scale - ZOOM_STEP * scale, r.width / 2, r.height / 2); });
142
+ document.getElementById('fit-all').addEventListener('click', fitAll);
143
+ document.getElementById('reset').addEventListener('click', resetZoom);
144
+ window.addEventListener('keydown', (e) => {
145
+ if (e.key === '=' || e.key === '+') { e.preventDefault(); const r = canvas.getBoundingClientRect(); zoomTo(scale + ZOOM_STEP * scale, r.width / 2, r.height / 2); }
146
+ else if (e.key === '-') { e.preventDefault(); const r = canvas.getBoundingClientRect(); zoomTo(scale - ZOOM_STEP * scale, r.width / 2, r.height / 2); }
147
+ else if (e.key === '0') { e.preventDefault(); fitAll(); }
148
+ else if (e.key === '1') { e.preventDefault(); resetZoom(); }
149
+ });
150
+
151
+ // ---- Export dropdown toggle ----
152
+ exportToggle.addEventListener('click', (e) => { e.stopPropagation(); exportDropdown.classList.toggle('open'); });
153
+ document.addEventListener('click', () => exportDropdown.classList.remove('open'));
154
+ exportDropdown.addEventListener('click', (e) => e.stopPropagation());
155
+
156
+ // ---- Toast ----
157
+ function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); }
158
+ function hideToast() { toast.classList.remove('show'); }
159
+
160
+ // ---- Render screenshot via Puppeteer server ----
161
+ async function renderScreenshot(file, width, height, sourceW, sourceH) {
162
+ const res = await fetch('/api/render-screenshot', {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify({ file, width, height, sourceW, sourceH }),
166
+ });
167
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
168
+ return await res.blob();
169
+ }
170
+
171
+ // ---- Helpers ----
172
+ function isSvgTemplate(template) {
173
+ return template.src.endsWith('.svg');
174
+ }
175
+
176
+ function flatSizes(col) {
177
+ return (col.exportSizes || []).flatMap(g => g.sizes || []);
178
+ }
179
+
180
+ // ---- Build collection tab bar ----
181
+ function buildTabBar() {
182
+ tabBar.innerHTML = '';
183
+ for (const col of manifest.collections) {
184
+ const a = document.createElement('a');
185
+ a.className = 'seg-btn' + (col.id === activeCollection.id ? ' active' : '');
186
+ a.href = '?collection=' + col.id;
187
+ a.textContent = col.label;
188
+ tabBar.appendChild(a);
189
+ }
190
+ }
191
+
192
+ // ---- Build canvas content (unified) ----
193
+ function buildCanvas() {
194
+ inner.innerHTML = '';
195
+ const col = activeCollection;
196
+ const sw = col.sourceSize.width;
197
+ const sh = col.sourceSize.height;
198
+ const br = col.borderRadius || 0;
199
+
200
+ for (const template of col.templates) {
201
+ const slot = document.createElement('div');
202
+ slot.className = 'screenshot-slot';
203
+
204
+ if (isSvgTemplate(template)) {
205
+ // SVG template: render as image with checkerboard background
206
+ const container = document.createElement('div');
207
+ container.className = 'logo-container';
208
+ container.style.width = sw + 'px';
209
+ container.style.height = sh + 'px';
210
+ if (br) container.style.borderRadius = br + 'px';
211
+
212
+ const img = document.createElement('img');
213
+ img.src = template.src;
214
+ img.style.width = sw + 'px';
215
+ img.style.height = sh + 'px';
216
+
217
+ container.appendChild(img);
218
+ slot.appendChild(container);
219
+ } else {
220
+ // HTML template: render as iframe
221
+ const border = document.createElement('div');
222
+ border.className = 'screenshot-border';
223
+ border.style.borderRadius = br + 'px';
224
+
225
+ const iframe = document.createElement('iframe');
226
+ iframe.src = template.src;
227
+ iframe.dataset.name = template.name;
228
+ iframe.dataset.file = template.src;
229
+ iframe.style.width = sw + 'px';
230
+ iframe.style.height = sh + 'px';
231
+
232
+ border.appendChild(iframe);
233
+ slot.appendChild(border);
234
+ }
235
+
236
+ const label = document.createElement('span');
237
+ label.className = 'screenshot-label';
238
+ label.textContent = template.label || template.name;
239
+ slot.appendChild(label);
240
+
241
+ inner.appendChild(slot);
242
+ }
243
+ }
244
+
245
+ // ---- Build export dropdown (unified) ----
246
+ function buildExportDropdown() {
247
+ exportDropdown.innerHTML = '';
248
+ const col = activeCollection;
249
+ const sw = col.sourceSize.width;
250
+ const sh = col.sourceSize.height;
251
+ const hasMultipleTemplates = col.templates.length > 1;
252
+
253
+ // Outputs (e.g., Xcode)
254
+ if (col.outputs) {
255
+ for (let i = 0; i < col.outputs.length; i++) {
256
+ const output = col.outputs[i];
257
+ if (output.type === 'xcode') {
258
+ const sectionLabel = document.createElement('div');
259
+ sectionLabel.className = 'section-label';
260
+ sectionLabel.textContent = 'Xcode';
261
+ exportDropdown.appendChild(sectionLabel);
262
+
263
+ const btn = document.createElement('button');
264
+ btn.innerHTML = 'Export to Xcode <span class="dim">.appiconset</span>';
265
+ btn.addEventListener('click', () => runOutput(col.id, i));
266
+ exportDropdown.appendChild(btn);
267
+
268
+ exportDropdown.appendChild(document.createElement('hr'));
269
+ } else if (output.type === 'copy-source') {
270
+ const sectionLabel = document.createElement('div');
271
+ sectionLabel.className = 'section-label';
272
+ sectionLabel.textContent = output.format.toUpperCase();
273
+ exportDropdown.appendChild(sectionLabel);
274
+
275
+ // SVG download for each template
276
+ for (const template of col.templates) {
277
+ if (template.src.endsWith('.' + output.format)) {
278
+ const btn = document.createElement('button');
279
+ const displayName = hasMultipleTemplates ? template.label : `Download ${output.format.toUpperCase()}`;
280
+ btn.innerHTML = `${displayName} <span class="dim">${output.format}</span>`;
281
+ btn.addEventListener('click', () => downloadSource(template));
282
+ exportDropdown.appendChild(btn);
283
+ }
284
+ }
285
+
286
+ exportDropdown.appendChild(document.createElement('hr'));
287
+ }
288
+ }
289
+ }
290
+
291
+ // Export sizes grouped by platform
292
+ for (const group of col.exportSizes || []) {
293
+ if (group.platform) {
294
+ const sectionLabel = document.createElement('div');
295
+ sectionLabel.className = 'section-label';
296
+ sectionLabel.textContent = group.platform;
297
+ exportDropdown.appendChild(sectionLabel);
298
+ }
299
+
300
+ for (const size of group.sizes || []) {
301
+ const btn = document.createElement('button');
302
+ btn.innerHTML = `${size.label} <span class="dim">${size.width} &times; ${size.height}</span>`;
303
+ btn.addEventListener('click', () => {
304
+ if (hasMultipleTemplates) {
305
+ exportZip(size.width, size.height, sw, sh, size.name);
306
+ } else {
307
+ exportSinglePNG(col.templates[0], size.width, size.height, sw, sh, size.name);
308
+ }
309
+ });
310
+ exportDropdown.appendChild(btn);
311
+ }
312
+ }
313
+
314
+ // Custom size
315
+ if (col.exportSizes && flatSizes(col).length > 0 || col.customExport) {
316
+ exportDropdown.appendChild(document.createElement('hr'));
317
+
318
+ const customLabel = document.createElement('div');
319
+ customLabel.className = 'section-label';
320
+ customLabel.textContent = 'Custom Size';
321
+ exportDropdown.appendChild(customLabel);
322
+
323
+ const defaultW = col.customExport?.defaultWidth || flatSizes(col)[0]?.width || sw;
324
+ const defaultH = col.customExport?.defaultHeight || flatSizes(col)[0]?.height || sh;
325
+
326
+ // Check if square (icon-like) for simplified input
327
+ const isSquare = sw === sh;
328
+
329
+ const row = document.createElement('div');
330
+ row.className = 'custom-row';
331
+
332
+ if (isSquare) {
333
+ row.innerHTML = `
334
+ <input type="number" id="custom-size" value="${defaultW}" min="1" style="width: 72px;">
335
+ <span>&times; same</span>
336
+ <button id="custom-export-btn">Export</button>
337
+ `;
338
+ } else {
339
+ row.innerHTML = `
340
+ <input type="number" id="custom-w" value="${defaultW}" min="1">
341
+ <span>&times;</span>
342
+ <input type="number" id="custom-h" value="${defaultH}" min="1">
343
+ <button id="custom-export-btn">Export</button>
344
+ `;
345
+ }
346
+ exportDropdown.appendChild(row);
347
+
348
+ requestAnimationFrame(() => {
349
+ const btn = document.getElementById('custom-export-btn');
350
+ if (btn) btn.addEventListener('click', () => {
351
+ let w, h;
352
+ if (isSquare) {
353
+ const size = parseInt(document.getElementById('custom-size').value);
354
+ if (!size || size < 1) return;
355
+ w = h = size;
356
+ } else {
357
+ w = parseInt(document.getElementById('custom-w').value);
358
+ h = parseInt(document.getElementById('custom-h').value);
359
+ if (!w || !h || w < 1 || h < 1) return;
360
+ }
361
+ const name = `${col.id}-${w}x${h}`;
362
+ if (hasMultipleTemplates) {
363
+ exportZip(w, h, sw, sh, name);
364
+ } else {
365
+ exportSinglePNG(col.templates[0], w, h, sw, sh, name);
366
+ }
367
+ });
368
+ });
369
+ }
370
+ }
371
+
372
+ // ---- Export actions ----
373
+
374
+ async function exportZip(w, h, sourceW, sourceH, zipName) {
375
+ exportDropdown.classList.remove('open');
376
+ const templates = activeCollection.templates;
377
+ const zip = new JSZip();
378
+ for (let i = 0; i < templates.length; i++) {
379
+ showToast(`Rendering ${i + 1} / ${templates.length}...`);
380
+ const t = templates[i];
381
+ if (isSvgTemplate(t)) {
382
+ // For SVG templates, render via Puppeteer at target size
383
+ const blob = await renderScreenshot(t.src, w, h, sourceW, sourceH);
384
+ zip.file(`${t.name}.png`, blob);
385
+ } else {
386
+ const blob = await renderScreenshot(t.src, w, h, sourceW, sourceH);
387
+ zip.file(`${t.name}.png`, blob);
388
+ }
389
+ }
390
+ showToast('Creating zip...');
391
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
392
+ saveAs(zipBlob, `${zipName}.zip`);
393
+ hideToast();
394
+ }
395
+
396
+ async function exportSinglePNG(template, w, h, sourceW, sourceH, name) {
397
+ exportDropdown.classList.remove('open');
398
+ showToast(`Rendering ${template.label || template.name} at ${w} × ${h}...`);
399
+ try {
400
+ const blob = await renderScreenshot(template.src, w, h, sourceW, sourceH);
401
+ saveAs(blob, `${template.name}-${w}x${h}.png`);
402
+ hideToast();
403
+ } catch (err) {
404
+ showToast('Failed — is dev server running?');
405
+ setTimeout(hideToast, 3000);
406
+ }
407
+ }
408
+
409
+ async function downloadSource(template) {
410
+ exportDropdown.classList.remove('open');
411
+ showToast('Downloading...');
412
+ try {
413
+ const res = await fetch(template.src);
414
+ const text = await res.text();
415
+ const ext = template.src.split('.').pop();
416
+ const mimeMap = { svg: 'image/svg+xml', html: 'text/html' };
417
+ const blob = new Blob([text], { type: mimeMap[ext] || 'application/octet-stream' });
418
+ saveAs(blob, `${template.name}.${ext}`);
419
+ hideToast();
420
+ } catch (err) {
421
+ showToast('Failed to download');
422
+ setTimeout(hideToast, 3000);
423
+ }
424
+ }
425
+
426
+ async function runOutput(collectionId, outputIndex) {
427
+ exportDropdown.classList.remove('open');
428
+ showToast('Running output...');
429
+ try {
430
+ const res = await fetch('/api/run-output', {
431
+ method: 'POST',
432
+ headers: { 'Content-Type': 'application/json' },
433
+ body: JSON.stringify({ collectionId, outputIndex }),
434
+ });
435
+ const data = await res.json();
436
+ if (data.ok) {
437
+ showToast('Output completed: ' + (data.path || 'success'));
438
+ } else {
439
+ showToast('Error: ' + (data.error || 'Unknown error'));
440
+ }
441
+ } catch (err) {
442
+ showToast('Failed — is dev server running?');
443
+ }
444
+ setTimeout(hideToast, 3000);
445
+ }
446
+
447
+ // ---- Init ----
448
+ async function init() {
449
+ const res = await fetch('manifest.json');
450
+ manifest = await res.json();
451
+
452
+ // Set title
453
+ document.title = manifest.name;
454
+ document.getElementById('app-title').textContent = manifest.name;
455
+
456
+ // Determine active collection from ?collection= param
457
+ const params = new URLSearchParams(window.location.search);
458
+ const colId = params.get('collection') || manifest.collections[0].id;
459
+ activeCollection = manifest.collections.find((c) => c.id === colId) || manifest.collections[0];
460
+
461
+ buildTabBar();
462
+ buildCanvas();
463
+ buildExportDropdown();
464
+ requestAnimationFrame(fitAll);
465
+ }
466
+
467
+ init();
468
+
469
+ // ---- Live reload ----
470
+ function connectLiveReload() {
471
+ const es = new EventSource('/api/live-reload');
472
+ es.onmessage = (event) => {
473
+ try {
474
+ const { file } = JSON.parse(event.data);
475
+ reloadChanged(file);
476
+ } catch {}
477
+ };
478
+ es.onerror = () => {
479
+ es.close();
480
+ setTimeout(connectLiveReload, 2000);
481
+ };
482
+ }
483
+
484
+ function reloadChanged(changedFile) {
485
+ const bust = '?_r=' + Date.now();
486
+
487
+ // Reload iframes whose src matches the changed file
488
+ const iframes = inner.querySelectorAll('iframe');
489
+ for (const iframe of iframes) {
490
+ const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
491
+ if (src && changedFile.endsWith(src.replace(/^\//, ''))) {
492
+ iframe.src = src + bust;
493
+ }
494
+ }
495
+
496
+ // Reload images whose src matches the changed file
497
+ const imgs = inner.querySelectorAll('img');
498
+ for (const img of imgs) {
499
+ const src = img.getAttribute('src')?.split('?')[0];
500
+ if (src && changedFile.endsWith(src.replace(/^\//, ''))) {
501
+ img.src = src + bust;
502
+ }
503
+ }
504
+
505
+ // For public dir assets (CSS, JS, etc.) used inside iframes, reload all iframes
506
+ const ext = changedFile.split('.').pop()?.toLowerCase();
507
+ if (['css', 'js', 'mjs', 'json', 'woff', 'woff2', 'ttf'].includes(ext)) {
508
+ for (const iframe of iframes) {
509
+ const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
510
+ if (src) iframe.src = src + bust;
511
+ }
512
+ }
513
+ }
514
+
515
+ connectLiveReload();
516
+ </script>
517
+ </body>
518
+ </html>