@veolab/discoverylab 1.3.4 → 1.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/chunk-34KRJWZL.js +477 -0
- package/dist/chunk-4VNS5WPM.js +42 -0
- package/dist/{chunk-FIL7IWEL.js → chunk-DGXAP477.js} +1 -1
- package/dist/{chunk-HGWEHWKJ.js → chunk-DKAX5RCX.js} +1 -1
- package/dist/{chunk-7EDIUVIO.js → chunk-EU63HPKT.js} +1 -1
- package/dist/chunk-QMUEC6B5.js +288 -0
- package/dist/{chunk-FNUN7EPB.js → chunk-RCY26WEK.js} +2 -2
- package/dist/{chunk-ZLHIHMSL.js → chunk-SWZIBO2R.js} +1 -1
- package/dist/chunk-VYYAP5G5.js +265 -0
- package/dist/{chunk-VVIOB362.js → chunk-XAMA3JJG.js} +18 -1
- package/dist/{chunk-BE7BFMYC.js → chunk-XWBFSSNB.js} +10224 -393
- package/dist/{chunk-AHVBE25Y.js → chunk-YNLUOZSZ.js} +274 -667
- package/dist/cli.js +33 -31
- package/dist/{db-6WLEVKUV.js → db-745LC5YC.js} +2 -2
- package/dist/document-AE4XI2CP.js +104 -0
- package/dist/{esvp-KVOWYW6G.js → esvp-4LIAU76K.js} +3 -3
- package/dist/{esvp-mobile-GZ5EMYPG.js → esvp-mobile-FKFHDS5Q.js} +4 -4
- package/dist/frames-RCNLSDD6.js +24 -0
- package/dist/{gridCompositor-M3K3LCLZ.js → gridCompositor-VUWBZXYL.js} +262 -3
- package/dist/index.d.ts +32 -0
- package/dist/index.html +1197 -9
- package/dist/index.js +15 -10
- package/dist/notion-api-OXSWOJPZ.js +190 -0
- package/dist/{ocr-QDYNCSPE.js → ocr-FXRLEP66.js} +1 -1
- package/dist/{playwright-VZ7PXDC5.js → playwright-GYKUH34L.js} +3 -3
- package/dist/renderer-D22GCMMD.js +17 -0
- package/dist/{server-6N3KIEGP.js → server-NTT2XGCC.js} +1 -1
- package/dist/server-TKYRIYJ6.js +24 -0
- package/dist/{setup-2SQC5UHJ.js → setup-O6WQQAGP.js} +3 -3
- package/dist/templates/bundle/bundle.js +4 -2
- package/dist/{tools-YGM5HRIB.js → tools-FVVWKEGC.js} +15 -7
- package/package.json +2 -2
- package/skills/knowledge-brain/SKILL.md +81 -0
- package/dist/chunk-MLKGABMK.js +0 -9
- package/dist/server-QKZXPZRC.js +0 -22
package/dist/index.html
CHANGED
|
@@ -6992,7 +6992,8 @@
|
|
|
6992
6992
|
</div>
|
|
6993
6993
|
<div class="selection-bar-right">
|
|
6994
6994
|
<button class="btn btn-secondary" id="cancelSelectBtn">Cancel</button>
|
|
6995
|
-
<button class="btn btn-primary" id="
|
|
6995
|
+
<button class="btn btn-primary" id="exportSelectedBtn" disabled>Export</button>
|
|
6996
|
+
<button class="btn btn-primary" id="deleteSelectedBtn" style="background: var(--error);">Delete</button>
|
|
6996
6997
|
</div>
|
|
6997
6998
|
</div>
|
|
6998
6999
|
|
|
@@ -7117,6 +7118,11 @@
|
|
|
7117
7118
|
<!-- Top: Images Carousel (Full Width) -->
|
|
7118
7119
|
<div class="grid-top-carousel">
|
|
7119
7120
|
<div class="carousel-header">
|
|
7121
|
+
<button class="icon-btn" id="gridBackBtn" title="Back to project" style="width: 28px; height: 28px; margin-right: 4px;">
|
|
7122
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
7123
|
+
<path d="M19 12H5"/><polyline points="12 19 5 12 12 5"/>
|
|
7124
|
+
</svg>
|
|
7125
|
+
</button>
|
|
7120
7126
|
<span class="carousel-title">IMAGES</span>
|
|
7121
7127
|
<button class="btn btn-small" id="addGridImages">+ Add</button>
|
|
7122
7128
|
</div>
|
|
@@ -8358,6 +8364,20 @@
|
|
|
8358
8364
|
let testingStatus = { maestro: false, playwright: false };
|
|
8359
8365
|
let selectMode = false;
|
|
8360
8366
|
let selectedProjects = new Set();
|
|
8367
|
+
let globalProviderLabel = '';
|
|
8368
|
+
// Fetch active LLM provider name once on load
|
|
8369
|
+
(async () => {
|
|
8370
|
+
try {
|
|
8371
|
+
const resp = await fetch('/api/settings/llm');
|
|
8372
|
+
const s = await resp.json();
|
|
8373
|
+
const pref = s.preferredProvider || 'auto';
|
|
8374
|
+
if (pref === 'anthropic' || (pref === 'auto' && s.anthropicApiKey)) globalProviderLabel = s.anthropicModel || 'Claude';
|
|
8375
|
+
else if (pref === 'openai' || (pref === 'auto' && s.openaiApiKey)) globalProviderLabel = s.openaiModel || 'GPT';
|
|
8376
|
+
else if (pref === 'ollama') globalProviderLabel = s.ollamaModel || 'Ollama';
|
|
8377
|
+
else if (pref === 'claude-cli') globalProviderLabel = 'Claude CLI';
|
|
8378
|
+
else globalProviderLabel = 'AI';
|
|
8379
|
+
} catch { globalProviderLabel = 'AI'; }
|
|
8380
|
+
})();
|
|
8361
8381
|
|
|
8362
8382
|
// Grid state
|
|
8363
8383
|
let gridImages = [];
|
|
@@ -10792,10 +10812,16 @@
|
|
|
10792
10812
|
const startUrl = urlInput.value.trim() || 'about:blank';
|
|
10793
10813
|
|
|
10794
10814
|
try {
|
|
10815
|
+
const captureSettings = getCaptureSettings();
|
|
10795
10816
|
const response = await fetch('/api/capture/web/start', {
|
|
10796
10817
|
method: 'POST',
|
|
10797
10818
|
headers: { 'Content-Type': 'application/json' },
|
|
10798
|
-
body: JSON.stringify({
|
|
10819
|
+
body: JSON.stringify({
|
|
10820
|
+
url: startUrl,
|
|
10821
|
+
captureResolution: captureSettings.captureResolution,
|
|
10822
|
+
viewportMode: captureSettings.viewportMode,
|
|
10823
|
+
viewportResolution: captureSettings.viewportResolution
|
|
10824
|
+
})
|
|
10799
10825
|
});
|
|
10800
10826
|
|
|
10801
10827
|
const data = await response.json();
|
|
@@ -11394,6 +11420,7 @@
|
|
|
11394
11420
|
const count = selectedProjects.size;
|
|
11395
11421
|
document.getElementById('selectionCount').textContent = `${count} selected`;
|
|
11396
11422
|
document.getElementById('deleteSelectedBtn').disabled = count === 0;
|
|
11423
|
+
document.getElementById('exportSelectedBtn').disabled = count === 0;
|
|
11397
11424
|
}
|
|
11398
11425
|
|
|
11399
11426
|
document.getElementById('selectModeBtn').addEventListener('click', () => {
|
|
@@ -11417,6 +11444,791 @@
|
|
|
11417
11444
|
updateSelectionCount();
|
|
11418
11445
|
});
|
|
11419
11446
|
|
|
11447
|
+
// ====================================================================
|
|
11448
|
+
// BATCH EXPORT MODAL
|
|
11449
|
+
// ====================================================================
|
|
11450
|
+
async function openBatchExportModal(projectIds) {
|
|
11451
|
+
const existing = document.getElementById('batchExportModal');
|
|
11452
|
+
if (existing) existing.remove();
|
|
11453
|
+
|
|
11454
|
+
// Fetch project details for selected projects
|
|
11455
|
+
const projectDetails = [];
|
|
11456
|
+
for (const id of projectIds) {
|
|
11457
|
+
const p = projects.find(pr => pr.id === id);
|
|
11458
|
+
if (p) {
|
|
11459
|
+
projectDetails.push({
|
|
11460
|
+
id: p.id,
|
|
11461
|
+
name: p.name,
|
|
11462
|
+
marketingTitle: p.marketingTitle || p.name,
|
|
11463
|
+
marketingDescription: p.marketingDescription || '',
|
|
11464
|
+
platform: p.platform || 'unknown',
|
|
11465
|
+
status: p.status,
|
|
11466
|
+
thumbnailPath: p.thumbnailPath,
|
|
11467
|
+
videoPath: p.videoPath,
|
|
11468
|
+
aiSummary: p.aiSummary,
|
|
11469
|
+
hasFrames: (p.frameCount || 0) > 0,
|
|
11470
|
+
});
|
|
11471
|
+
}
|
|
11472
|
+
}
|
|
11473
|
+
|
|
11474
|
+
if (!projectDetails.length) {
|
|
11475
|
+
showToast('No valid projects selected', 'error');
|
|
11476
|
+
return;
|
|
11477
|
+
}
|
|
11478
|
+
|
|
11479
|
+
const modal = document.createElement('div');
|
|
11480
|
+
modal.className = 'modal-overlay active';
|
|
11481
|
+
modal.id = 'batchExportModal';
|
|
11482
|
+
modal.innerHTML = `
|
|
11483
|
+
<style>
|
|
11484
|
+
#batchExportModal .export-modal { max-width: 800px; max-height: 85vh; display: flex; flex-direction: column; }
|
|
11485
|
+
#batchExportModal .export-stepper { display: flex; gap: 0; border-bottom: 1px solid var(--border); padding: 0 16px; }
|
|
11486
|
+
#batchExportModal .export-step { padding: 10px 16px; font-size: 12px; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
|
11487
|
+
#batchExportModal .export-step.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
|
11488
|
+
#batchExportModal .export-step.done { color: var(--success); }
|
|
11489
|
+
#batchExportModal .export-body { padding: 16px; overflow-y: auto; flex: 1; }
|
|
11490
|
+
#batchExportModal .export-project-row { display: grid; grid-template-columns: 48px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
|
11491
|
+
#batchExportModal .export-project-row:last-child { border-bottom: none; }
|
|
11492
|
+
#batchExportModal .export-project-thumb { width: 48px; height: 48px; border-radius: 8px; overflow: hidden; background: var(--bg-tertiary); }
|
|
11493
|
+
#batchExportModal .export-project-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
11494
|
+
#batchExportModal .export-project-fields { display: flex; flex-direction: column; gap: 6px; }
|
|
11495
|
+
#batchExportModal .export-field label { display: block; font-size: 10px; font-weight: 500; color: var(--text-muted); margin-bottom: 2px; }
|
|
11496
|
+
#batchExportModal .export-field input, #batchExportModal .export-field textarea { width: 100%; padding: 6px 8px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 12px; box-sizing: border-box; font-family: inherit; }
|
|
11497
|
+
#batchExportModal .export-field textarea { resize: vertical; min-height: 40px; }
|
|
11498
|
+
#batchExportModal .export-field input:focus, #batchExportModal .export-field textarea:focus { outline: none; border-color: var(--accent); }
|
|
11499
|
+
#batchExportModal .export-regen-btn { font-size: 10px; color: var(--accent); cursor: pointer; background: none; border: none; padding: 2px 0; }
|
|
11500
|
+
#batchExportModal .export-regen-btn:hover { text-decoration: underline; }
|
|
11501
|
+
#batchExportModal .export-assets-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; margin-top: 8px; }
|
|
11502
|
+
#batchExportModal .export-asset-card { padding: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; text-align: center; transition: all 0.2s; }
|
|
11503
|
+
#batchExportModal .export-asset-card.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); }
|
|
11504
|
+
#batchExportModal .export-asset-card .asset-icon { margin-bottom: 6px; display: flex; justify-content: center; }
|
|
11505
|
+
#batchExportModal .export-asset-card .asset-label { font-size: 11px; font-weight: 500; color: var(--text-primary); }
|
|
11506
|
+
#batchExportModal .export-asset-card .asset-desc { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
|
11507
|
+
#batchExportModal .export-dest-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin-top: 8px; }
|
|
11508
|
+
#batchExportModal .export-dest-card { padding: 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; text-align: center; transition: all 0.2s; }
|
|
11509
|
+
#batchExportModal .export-dest-card.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); }
|
|
11510
|
+
#batchExportModal .export-dest-card .dest-label { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
|
11511
|
+
#batchExportModal .export-dest-card .dest-desc { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
|
|
11512
|
+
#batchExportModal .export-progress { margin-top: 12px; }
|
|
11513
|
+
#batchExportModal .export-progress-bar { height: 4px; background: var(--bg-tertiary); border-radius: 2px; overflow: hidden; }
|
|
11514
|
+
#batchExportModal .export-progress-fill { height: 100%; background: var(--accent); transition: width 0.3s; width: 0%; }
|
|
11515
|
+
#batchExportModal .export-progress-text { font-size: 11px; color: var(--text-secondary); margin-top: 4px; }
|
|
11516
|
+
#batchExportModal .export-btn-row { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid var(--border); }
|
|
11517
|
+
</style>
|
|
11518
|
+
<div class="modal export-modal">
|
|
11519
|
+
<div class="modal-header">
|
|
11520
|
+
<div class="modal-title">Export ${projectDetails.length} Project${projectDetails.length > 1 ? 's' : ''}</div>
|
|
11521
|
+
<button class="icon-btn" onclick="document.getElementById('batchExportModal')?.remove()">
|
|
11522
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
11523
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
11524
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
11525
|
+
</svg>
|
|
11526
|
+
</button>
|
|
11527
|
+
</div>
|
|
11528
|
+
<div class="export-stepper">
|
|
11529
|
+
<div class="export-step active" data-step="1">1. Review</div>
|
|
11530
|
+
<div class="export-step" data-step="2">2. Compose</div>
|
|
11531
|
+
<div class="export-step" data-step="3">3. Destination</div>
|
|
11532
|
+
</div>
|
|
11533
|
+
<div class="export-body" id="exportModalBody"></div>
|
|
11534
|
+
<div class="export-btn-row">
|
|
11535
|
+
<button class="btn btn-secondary" id="exportPrevBtn" style="display:none">Back</button>
|
|
11536
|
+
<button class="btn btn-primary" id="exportNextBtn">Next</button>
|
|
11537
|
+
</div>
|
|
11538
|
+
</div>
|
|
11539
|
+
`;
|
|
11540
|
+
document.body.appendChild(modal);
|
|
11541
|
+
|
|
11542
|
+
// State
|
|
11543
|
+
let currentStep = 1;
|
|
11544
|
+
const exportState = {
|
|
11545
|
+
projects: projectDetails.map(p => ({
|
|
11546
|
+
projectId: p.id,
|
|
11547
|
+
title: p.marketingTitle || p.name,
|
|
11548
|
+
description: p.marketingDescription || '',
|
|
11549
|
+
assets: [
|
|
11550
|
+
{ type: 'video', include: !!p.videoPath },
|
|
11551
|
+
{ type: 'grid', include: p.hasFrames },
|
|
11552
|
+
{ type: 'frames', include: false },
|
|
11553
|
+
{ type: 'visualization', include: false },
|
|
11554
|
+
]
|
|
11555
|
+
})),
|
|
11556
|
+
destination: { type: 'notion', config: {} },
|
|
11557
|
+
document: null, // loaded in compose step
|
|
11558
|
+
};
|
|
11559
|
+
|
|
11560
|
+
function renderStep(step) {
|
|
11561
|
+
currentStep = step;
|
|
11562
|
+
const body = document.getElementById('exportModalBody');
|
|
11563
|
+
const prevBtn = document.getElementById('exportPrevBtn');
|
|
11564
|
+
const nextBtn = document.getElementById('exportNextBtn');
|
|
11565
|
+
|
|
11566
|
+
// Update stepper
|
|
11567
|
+
modal.querySelectorAll('.export-step').forEach(s => {
|
|
11568
|
+
const sNum = parseInt(s.dataset.step);
|
|
11569
|
+
s.classList.toggle('active', sNum === step);
|
|
11570
|
+
s.classList.toggle('done', sNum < step);
|
|
11571
|
+
});
|
|
11572
|
+
|
|
11573
|
+
prevBtn.style.display = step > 1 ? '' : 'none';
|
|
11574
|
+
nextBtn.textContent = step === 3 ? 'Export' : 'Next';
|
|
11575
|
+
|
|
11576
|
+
if (step === 1) renderReviewStep(body);
|
|
11577
|
+
else if (step === 2) renderComposeStep(body);
|
|
11578
|
+
else if (step === 3) renderDestinationStep(body);
|
|
11579
|
+
}
|
|
11580
|
+
|
|
11581
|
+
// Fetch active LLM provider name for display
|
|
11582
|
+
let activeProviderLabel = 'AI';
|
|
11583
|
+
(async () => {
|
|
11584
|
+
try {
|
|
11585
|
+
const resp = await fetch('/api/settings/llm');
|
|
11586
|
+
const s = await resp.json();
|
|
11587
|
+
const pref = s.preferredProvider || 'auto';
|
|
11588
|
+
if (pref === 'anthropic' || (pref === 'auto' && s.anthropicApiKey)) {
|
|
11589
|
+
activeProviderLabel = s.anthropicModel || 'Claude';
|
|
11590
|
+
} else if (pref === 'openai' || (pref === 'auto' && s.openaiApiKey)) {
|
|
11591
|
+
activeProviderLabel = s.openaiModel || 'GPT';
|
|
11592
|
+
} else if (pref === 'ollama') {
|
|
11593
|
+
activeProviderLabel = s.ollamaModel || 'Ollama';
|
|
11594
|
+
} else if (pref === 'claude-cli') {
|
|
11595
|
+
activeProviderLabel = 'Claude CLI';
|
|
11596
|
+
}
|
|
11597
|
+
// Update buttons if already rendered
|
|
11598
|
+
modal.querySelectorAll('.export-regen-btn .provider-name').forEach(el => {
|
|
11599
|
+
el.textContent = activeProviderLabel;
|
|
11600
|
+
});
|
|
11601
|
+
} catch { /* ignore */ }
|
|
11602
|
+
})();
|
|
11603
|
+
|
|
11604
|
+
function renderReviewStep(body) {
|
|
11605
|
+
body.innerHTML = projectDetails.map((p, i) => {
|
|
11606
|
+
const thumbUrl = p.thumbnailPath ? `/api/file?path=${encodeURIComponent(p.thumbnailPath)}` : '';
|
|
11607
|
+
return `
|
|
11608
|
+
<div class="export-project-row">
|
|
11609
|
+
<div class="export-project-thumb">
|
|
11610
|
+
${thumbUrl ? `<img src="${thumbUrl}" alt="">` : ''}
|
|
11611
|
+
</div>
|
|
11612
|
+
<div class="export-project-fields">
|
|
11613
|
+
<div class="export-field">
|
|
11614
|
+
<label>Title</label>
|
|
11615
|
+
<input type="text" value="${(exportState.projects[i].title || '').replace(/"/g, '"')}" data-idx="${i}" data-field="title">
|
|
11616
|
+
</div>
|
|
11617
|
+
<div class="export-field" style="position: relative;">
|
|
11618
|
+
<label style="display: flex; align-items: center; gap: 6px;">
|
|
11619
|
+
Description
|
|
11620
|
+
<button class="export-regen-btn" data-idx="${i}" style="display: flex; align-items: center; gap: 3px;">
|
|
11621
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6v6l4 2"/></svg>
|
|
11622
|
+
<span class="provider-name">${activeProviderLabel}</span>
|
|
11623
|
+
</button>
|
|
11624
|
+
</label>
|
|
11625
|
+
<div style="position: relative;">
|
|
11626
|
+
<textarea rows="2" data-idx="${i}" data-field="description">${exportState.projects[i].description || ''}</textarea>
|
|
11627
|
+
<div class="export-ai-progress" data-idx="${i}" style="display: none; position: absolute; top: 6px; right: 6px; font-size: 9px; color: var(--accent); background: var(--bg-primary); padding: 2px 6px; border-radius: 10px; border: 1px solid var(--border); align-items: center; gap: 3px;">
|
|
11628
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite;"><path d="M12 2a10 10 0 1 0 10 10"/></svg>
|
|
11629
|
+
<span class="provider-name">${activeProviderLabel}</span>
|
|
11630
|
+
</div>
|
|
11631
|
+
</div>
|
|
11632
|
+
</div>
|
|
11633
|
+
</div>
|
|
11634
|
+
</div>
|
|
11635
|
+
`;
|
|
11636
|
+
}).join('');
|
|
11637
|
+
|
|
11638
|
+
// Add spin keyframe if not exists
|
|
11639
|
+
if (!document.getElementById('exportSpinStyle')) {
|
|
11640
|
+
const style = document.createElement('style');
|
|
11641
|
+
style.id = 'exportSpinStyle';
|
|
11642
|
+
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
|
|
11643
|
+
document.head.appendChild(style);
|
|
11644
|
+
}
|
|
11645
|
+
|
|
11646
|
+
// Bind input changes
|
|
11647
|
+
body.querySelectorAll('input[data-field], textarea[data-field]').forEach(el => {
|
|
11648
|
+
el.addEventListener('input', () => {
|
|
11649
|
+
const idx = parseInt(el.dataset.idx);
|
|
11650
|
+
exportState.projects[idx][el.dataset.field] = el.value;
|
|
11651
|
+
});
|
|
11652
|
+
});
|
|
11653
|
+
|
|
11654
|
+
// Bind AI regenerate buttons
|
|
11655
|
+
body.querySelectorAll('.export-regen-btn').forEach(btn => {
|
|
11656
|
+
btn.addEventListener('click', async () => {
|
|
11657
|
+
const idx = parseInt(btn.dataset.idx);
|
|
11658
|
+
const pid = projectDetails[idx].id;
|
|
11659
|
+
const progressEl = body.querySelector(`.export-ai-progress[data-idx="${idx}"]`);
|
|
11660
|
+
const textarea = body.querySelector(`textarea[data-idx="${idx}"]`);
|
|
11661
|
+
btn.style.opacity = '0.4';
|
|
11662
|
+
btn.disabled = true;
|
|
11663
|
+
if (progressEl) progressEl.style.display = 'flex';
|
|
11664
|
+
if (textarea) textarea.style.opacity = '0.5';
|
|
11665
|
+
try {
|
|
11666
|
+
const resp = await fetch('/api/ai/marketing-description', {
|
|
11667
|
+
method: 'POST',
|
|
11668
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11669
|
+
body: JSON.stringify({ projectId: pid })
|
|
11670
|
+
});
|
|
11671
|
+
const data = await resp.json();
|
|
11672
|
+
if (data.success) {
|
|
11673
|
+
exportState.projects[idx].description = data.marketingDescription;
|
|
11674
|
+
if (data.marketingTitle) exportState.projects[idx].title = data.marketingTitle;
|
|
11675
|
+
renderStep(1);
|
|
11676
|
+
} else {
|
|
11677
|
+
showToast(data.error || 'Failed to generate', 'error');
|
|
11678
|
+
}
|
|
11679
|
+
} catch {
|
|
11680
|
+
showToast('Failed to generate description', 'error');
|
|
11681
|
+
} finally {
|
|
11682
|
+
btn.style.opacity = '';
|
|
11683
|
+
btn.disabled = false;
|
|
11684
|
+
if (progressEl) progressEl.style.display = 'none';
|
|
11685
|
+
if (textarea) textarea.style.opacity = '';
|
|
11686
|
+
}
|
|
11687
|
+
});
|
|
11688
|
+
});
|
|
11689
|
+
}
|
|
11690
|
+
|
|
11691
|
+
async function renderComposeStep(body) {
|
|
11692
|
+
// Load documents for ALL projects
|
|
11693
|
+
if (!exportState.documents) {
|
|
11694
|
+
body.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 12px;">Loading documents...</div>';
|
|
11695
|
+
exportState.documents = {};
|
|
11696
|
+
for (const p of projectDetails) {
|
|
11697
|
+
try {
|
|
11698
|
+
const resp = await fetch(`/api/export/document/${p.id}`);
|
|
11699
|
+
const data = await resp.json();
|
|
11700
|
+
if (data.success) exportState.documents[p.id] = data.document;
|
|
11701
|
+
} catch { /* skip */ }
|
|
11702
|
+
if (!exportState.documents[p.id]) {
|
|
11703
|
+
exportState.documents[p.id] = {
|
|
11704
|
+
title: p.marketingTitle || p.name,
|
|
11705
|
+
sections: [
|
|
11706
|
+
{ type: 'callout', text: p.marketingTitle || p.name, color: 'purple' },
|
|
11707
|
+
{ type: 'paragraph', text: p.marketingDescription || '' },
|
|
11708
|
+
],
|
|
11709
|
+
};
|
|
11710
|
+
}
|
|
11711
|
+
}
|
|
11712
|
+
// Also set first doc as exportState.document for backward compat
|
|
11713
|
+
exportState.document = exportState.documents[projectDetails[0].id];
|
|
11714
|
+
}
|
|
11715
|
+
|
|
11716
|
+
if (!exportState._activeComposeProject) {
|
|
11717
|
+
exportState._activeComposeProject = projectDetails[0].id;
|
|
11718
|
+
}
|
|
11719
|
+
const activeId = exportState._activeComposeProject;
|
|
11720
|
+
const doc = exportState.documents[activeId];
|
|
11721
|
+
if (!doc) return;
|
|
11722
|
+
|
|
11723
|
+
const sectionLabels = {
|
|
11724
|
+
callout: 'Header', paragraph: 'Description', divider: 'Divider',
|
|
11725
|
+
links: 'Links', 'image-gallery': 'Screenshots', image: 'Image',
|
|
11726
|
+
video: 'Video', gif: 'Interactive', grid: 'Grid', markdown: 'Analysis', heading: 'Heading',
|
|
11727
|
+
};
|
|
11728
|
+
|
|
11729
|
+
function renderSection(section, idx) {
|
|
11730
|
+
const label = sectionLabels[section.type] || section.type;
|
|
11731
|
+
let preview = '';
|
|
11732
|
+
|
|
11733
|
+
switch (section.type) {
|
|
11734
|
+
case 'callout':
|
|
11735
|
+
preview = `<input type="text" class="compose-edit" data-section="${idx}" data-field="text" value="${(section.text || '').replace(/"/g, '"')}" style="width: 100%; padding: 6px 8px; background: rgba(147,51,234,0.08); border-left: 3px solid rgba(147,51,234,0.4); border-radius: 4px; font-size: 12px; font-weight: 600; border: none; color: var(--text-primary); outline: none;">`;
|
|
11736
|
+
break;
|
|
11737
|
+
case 'paragraph':
|
|
11738
|
+
preview = `<textarea class="compose-edit" data-section="${idx}" data-field="text" rows="2" style="width: 100%; font-size: 11px; color: var(--text-secondary); line-height: 1.5; background: transparent; border: 1px solid transparent; border-radius: 4px; resize: vertical; padding: 4px 6px; outline: none; font-family: inherit;" onfocus="this.style.borderColor='var(--border)'" onblur="this.style.borderColor='transparent'">${section.text || ''}</textarea>`;
|
|
11739
|
+
break;
|
|
11740
|
+
case 'divider':
|
|
11741
|
+
preview = '<hr style="border: none; border-top: 1px solid var(--border); margin: 2px 0;">';
|
|
11742
|
+
break;
|
|
11743
|
+
case 'links':
|
|
11744
|
+
preview = (section.items || []).map(l =>
|
|
11745
|
+
`<a href="${l.url}" target="_blank" style="font-size: 10px; padding: 2px 6px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 4px; display: inline-flex; align-items: center; gap: 3px; margin: 1px; color: var(--accent); text-decoration: none;">${l.linkType} ${(l.label || '').slice(0, 25)}</a>`
|
|
11746
|
+
).join(' ') || '<span style="font-size: 10px; opacity: 0.4;">No links</span>';
|
|
11747
|
+
break;
|
|
11748
|
+
case 'image-gallery': {
|
|
11749
|
+
const imgs = section.images || [];
|
|
11750
|
+
const selectedCount = imgs.filter(i => i.selected !== false).length;
|
|
11751
|
+
preview = `
|
|
11752
|
+
<div style="display: flex; gap: 4px; overflow-x: auto; padding: 4px 0;">
|
|
11753
|
+
${imgs.map((img, j) => `
|
|
11754
|
+
<div class="compose-thumb" data-section="${idx}" data-img="${j}"
|
|
11755
|
+
style="width: 48px; height: 80px; border-radius: 4px; overflow: hidden;
|
|
11756
|
+
border: 2px solid ${img.selected !== false ? 'var(--accent)' : 'var(--border)'};
|
|
11757
|
+
cursor: pointer; flex-shrink: 0;
|
|
11758
|
+
opacity: ${img.selected !== false ? '1' : '0.3'};
|
|
11759
|
+
transition: all 0.15s;">
|
|
11760
|
+
<img src="/api/file?path=${encodeURIComponent(img.path)}" style="width: 100%; height: 100%; object-fit: cover;" loading="lazy">
|
|
11761
|
+
</div>
|
|
11762
|
+
`).join('')}
|
|
11763
|
+
</div>
|
|
11764
|
+
<div style="font-size: 9px; color: var(--text-muted); margin-top: 3px;">${selectedCount} of ${imgs.length} selected - click to toggle</div>
|
|
11765
|
+
`;
|
|
11766
|
+
break;
|
|
11767
|
+
}
|
|
11768
|
+
case 'video': {
|
|
11769
|
+
const videoUrl = section.path ? `/api/file?path=${encodeURIComponent(section.path)}` : '';
|
|
11770
|
+
const dur = section.duration ? `${Math.floor(section.duration / 60)}:${String(Math.floor(section.duration % 60)).padStart(2, '0')}` : '';
|
|
11771
|
+
preview = videoUrl ? `
|
|
11772
|
+
<div style="border-radius: 6px; overflow: hidden; background: #000; max-height: 120px;">
|
|
11773
|
+
<video src="${videoUrl}" style="width: 100%; max-height: 120px; object-fit: contain;" controls preload="metadata"></video>
|
|
11774
|
+
</div>
|
|
11775
|
+
${dur ? `<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">${section.path?.includes('template-') ? 'Template render' : 'Original recording'} (${dur})</div>` : ''}
|
|
11776
|
+
` : '<div style="font-size: 10px; opacity: 0.4;">No video</div>';
|
|
11777
|
+
break;
|
|
11778
|
+
}
|
|
11779
|
+
case 'gif': {
|
|
11780
|
+
const tplId = section.templateId || 'flow-diagram';
|
|
11781
|
+
preview = `
|
|
11782
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
11783
|
+
<select class="compose-edit" data-section="${idx}" data-field="templateId" style="font-size: 10px; padding: 3px 6px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary);">
|
|
11784
|
+
<option value="flow-diagram" ${tplId === 'flow-diagram' ? 'selected' : ''}>Flow Diagram</option>
|
|
11785
|
+
<option value="device-showcase" ${tplId === 'device-showcase' ? 'selected' : ''}>Device Showcase</option>
|
|
11786
|
+
<option value="metrics-dashboard" ${tplId === 'metrics-dashboard' ? 'selected' : ''}>Metrics Dashboard</option>
|
|
11787
|
+
<option value="app-flow-map" ${tplId === 'app-flow-map' ? 'selected' : ''}>App Flow Map</option>
|
|
11788
|
+
</select>
|
|
11789
|
+
<span style="font-size: 9px; color: var(--text-muted);">Exported as GIF</span>
|
|
11790
|
+
</div>
|
|
11791
|
+
`;
|
|
11792
|
+
break;
|
|
11793
|
+
}
|
|
11794
|
+
case 'markdown': {
|
|
11795
|
+
const lines = (section.content || '').split('\n').filter(l => l.trim()).slice(0, 4);
|
|
11796
|
+
preview = `<div style="font-size: 10px; color: var(--text-muted); padding: 4px 8px; background: var(--bg-primary); border-radius: 4px; max-height: 60px; overflow: hidden; line-height: 1.5;">
|
|
11797
|
+
${section.label ? `<div style="font-weight: 600; margin-bottom: 2px;">${section.label}</div>` : ''}
|
|
11798
|
+
${lines.map(l => `<div>${l.replace(/^#+\s/, '').slice(0, 60)}</div>`).join('')}
|
|
11799
|
+
${(section.content || '').split('\n').length > 4 ? '<div style="opacity: 0.4;">...</div>' : ''}
|
|
11800
|
+
</div>`;
|
|
11801
|
+
break;
|
|
11802
|
+
}
|
|
11803
|
+
default:
|
|
11804
|
+
preview = `<div style="font-size: 10px; opacity: 0.4;">${section.type}</div>`;
|
|
11805
|
+
}
|
|
11806
|
+
|
|
11807
|
+
return `
|
|
11808
|
+
<div class="compose-section" data-idx="${idx}" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 4px;">
|
|
11809
|
+
<div style="display: flex; align-items: center; gap: 4px; margin-bottom: 4px;">
|
|
11810
|
+
<div style="display: flex; flex-direction: column; gap: 0;">
|
|
11811
|
+
${idx > 0 ? `<button class="compose-move" data-idx="${idx}" data-dir="up" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 8px; line-height: 1;">▲</button>` : '<span style="width:8px;"></span>'}
|
|
11812
|
+
${idx < doc.sections.length - 1 ? `<button class="compose-move" data-idx="${idx}" data-dir="down" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 8px; line-height: 1;">▼</button>` : ''}
|
|
11813
|
+
</div>
|
|
11814
|
+
<span style="font-size: 9px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">${label}</span>
|
|
11815
|
+
<button class="compose-remove" data-idx="${idx}" style="margin-left: auto; background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px; font-size: 11px; opacity: 0.5;">×</button>
|
|
11816
|
+
</div>
|
|
11817
|
+
${preview}
|
|
11818
|
+
</div>
|
|
11819
|
+
`;
|
|
11820
|
+
}
|
|
11821
|
+
|
|
11822
|
+
// Project tabs (if multiple)
|
|
11823
|
+
const tabs = projectDetails.length > 1 ? `
|
|
11824
|
+
<div style="display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 8px; overflow-x: auto;">
|
|
11825
|
+
${projectDetails.map(p => `
|
|
11826
|
+
<button class="compose-tab" data-pid="${p.id}" style="padding: 6px 12px; font-size: 11px; border: none; background: none; cursor: pointer; color: ${p.id === activeId ? 'var(--accent)' : 'var(--text-muted)'}; border-bottom: 2px solid ${p.id === activeId ? 'var(--accent)' : 'transparent'}; white-space: nowrap; transition: all 0.15s;">
|
|
11827
|
+
${(p.marketingTitle || p.name).slice(0, 20)}
|
|
11828
|
+
</button>
|
|
11829
|
+
`).join('')}
|
|
11830
|
+
</div>
|
|
11831
|
+
` : '';
|
|
11832
|
+
|
|
11833
|
+
body.innerHTML = `
|
|
11834
|
+
${tabs}
|
|
11835
|
+
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 6px;">Page preview - reorder, remove, or select frames</div>
|
|
11836
|
+
<div id="composeSections">
|
|
11837
|
+
${doc.sections.map((s, i) => renderSection(s, i)).join('')}
|
|
11838
|
+
</div>
|
|
11839
|
+
`;
|
|
11840
|
+
|
|
11841
|
+
// Tab switching
|
|
11842
|
+
body.querySelectorAll('.compose-tab').forEach(tab => {
|
|
11843
|
+
tab.addEventListener('click', () => {
|
|
11844
|
+
exportState._activeComposeProject = tab.dataset.pid;
|
|
11845
|
+
renderComposeStep(body);
|
|
11846
|
+
});
|
|
11847
|
+
});
|
|
11848
|
+
|
|
11849
|
+
// Frame selection toggle
|
|
11850
|
+
body.querySelectorAll('.compose-thumb').forEach(thumb => {
|
|
11851
|
+
thumb.addEventListener('click', (e) => {
|
|
11852
|
+
e.stopPropagation();
|
|
11853
|
+
const sIdx = parseInt(thumb.dataset.section);
|
|
11854
|
+
const iIdx = parseInt(thumb.dataset.img);
|
|
11855
|
+
const section = doc.sections[sIdx];
|
|
11856
|
+
if (section.type === 'image-gallery') {
|
|
11857
|
+
const img = section.images[iIdx];
|
|
11858
|
+
img.selected = img.selected === false ? true : false;
|
|
11859
|
+
renderComposeStep(body);
|
|
11860
|
+
}
|
|
11861
|
+
});
|
|
11862
|
+
});
|
|
11863
|
+
|
|
11864
|
+
// Move sections
|
|
11865
|
+
body.querySelectorAll('.compose-move').forEach(btn => {
|
|
11866
|
+
btn.addEventListener('click', (e) => {
|
|
11867
|
+
e.stopPropagation();
|
|
11868
|
+
const idx = parseInt(btn.dataset.idx);
|
|
11869
|
+
const dir = btn.dataset.dir;
|
|
11870
|
+
const targetIdx = dir === 'up' ? idx - 1 : idx + 1;
|
|
11871
|
+
if (targetIdx < 0 || targetIdx >= doc.sections.length) return;
|
|
11872
|
+
[doc.sections[idx], doc.sections[targetIdx]] = [doc.sections[targetIdx], doc.sections[idx]];
|
|
11873
|
+
renderComposeStep(body);
|
|
11874
|
+
});
|
|
11875
|
+
});
|
|
11876
|
+
|
|
11877
|
+
// Remove sections
|
|
11878
|
+
body.querySelectorAll('.compose-remove').forEach(btn => {
|
|
11879
|
+
btn.addEventListener('click', (e) => {
|
|
11880
|
+
e.stopPropagation();
|
|
11881
|
+
doc.sections.splice(parseInt(btn.dataset.idx), 1);
|
|
11882
|
+
renderComposeStep(body);
|
|
11883
|
+
});
|
|
11884
|
+
});
|
|
11885
|
+
|
|
11886
|
+
// Inline edit sync (inputs, textareas, selects)
|
|
11887
|
+
body.querySelectorAll('.compose-edit').forEach(el => {
|
|
11888
|
+
const handler = () => {
|
|
11889
|
+
const sIdx = parseInt(el.dataset.section);
|
|
11890
|
+
const field = el.dataset.field;
|
|
11891
|
+
if (doc.sections[sIdx] && field) {
|
|
11892
|
+
doc.sections[sIdx][field] = el.value;
|
|
11893
|
+
}
|
|
11894
|
+
};
|
|
11895
|
+
el.addEventListener('input', handler);
|
|
11896
|
+
el.addEventListener('change', handler);
|
|
11897
|
+
});
|
|
11898
|
+
}
|
|
11899
|
+
|
|
11900
|
+
async function renderDestinationStep(body) {
|
|
11901
|
+
const destinations = [
|
|
11902
|
+
{ type: 'notion', label: 'Notion', desc: 'Create pages in Notion' },
|
|
11903
|
+
{ type: 'local', label: 'Local Download', desc: 'Save files locally' },
|
|
11904
|
+
];
|
|
11905
|
+
|
|
11906
|
+
body.innerHTML = `
|
|
11907
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">Where to export:</div>
|
|
11908
|
+
<div class="export-dest-grid">
|
|
11909
|
+
${destinations.map(d => `
|
|
11910
|
+
<div class="export-dest-card ${exportState.destination.type === d.type ? 'selected' : ''}" data-dest="${d.type}">
|
|
11911
|
+
<div class="dest-label">${d.label}</div>
|
|
11912
|
+
<div class="dest-desc">${d.desc}</div>
|
|
11913
|
+
</div>
|
|
11914
|
+
`).join('')}
|
|
11915
|
+
</div>
|
|
11916
|
+
<div id="notionConfigArea" style="display: ${exportState.destination.type === 'notion' ? 'block' : 'none'}; margin-top: 12px;">
|
|
11917
|
+
<div id="notionStatusArea" style="margin-bottom: 8px;">
|
|
11918
|
+
<span style="font-size: 11px; color: var(--text-muted);">Checking Notion connection...</span>
|
|
11919
|
+
</div>
|
|
11920
|
+
<div id="notionTokenArea" style="display: none; margin-bottom: 10px;">
|
|
11921
|
+
<label style="display: block; font-size: 10px; font-weight: 500; color: var(--text-muted); margin-bottom: 3px;">Notion Integration Token</label>
|
|
11922
|
+
<div style="display: flex; gap: 6px;">
|
|
11923
|
+
<input type="password" id="notionTokenInput" placeholder="ntn_..." style="flex: 1; padding: 6px 8px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 12px;">
|
|
11924
|
+
<button class="btn btn-primary" id="notionSaveTokenBtn" style="font-size: 11px; padding: 6px 10px;">Connect</button>
|
|
11925
|
+
</div>
|
|
11926
|
+
<div style="font-size: 10px; color: var(--text-muted); margin-top: 3px;">
|
|
11927
|
+
Create at <a href="https://www.notion.so/my-integrations" target="_blank" style="color: var(--accent);">notion.so/my-integrations</a> → give it page access
|
|
11928
|
+
</div>
|
|
11929
|
+
</div>
|
|
11930
|
+
<div id="notionPagePickerArea" style="display: none;">
|
|
11931
|
+
<label style="display: block; font-size: 10px; font-weight: 500; color: var(--text-muted); margin-bottom: 3px;">Parent Page</label>
|
|
11932
|
+
<div style="display: flex; gap: 6px; margin-bottom: 6px;">
|
|
11933
|
+
<input type="text" id="notionPageSearch" placeholder="Search pages..." style="flex: 1; padding: 6px 8px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 12px;">
|
|
11934
|
+
</div>
|
|
11935
|
+
<div id="notionPageList" style="max-height: 150px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary);"></div>
|
|
11936
|
+
</div>
|
|
11937
|
+
</div>
|
|
11938
|
+
<div class="export-progress" id="exportProgressArea" style="display: none;">
|
|
11939
|
+
<div class="export-progress-bar">
|
|
11940
|
+
<div class="export-progress-fill" id="exportProgressFill"></div>
|
|
11941
|
+
</div>
|
|
11942
|
+
<div class="export-progress-text" id="exportProgressText"></div>
|
|
11943
|
+
</div>
|
|
11944
|
+
`;
|
|
11945
|
+
|
|
11946
|
+
// Destination card selection
|
|
11947
|
+
body.querySelectorAll('.export-dest-card').forEach(card => {
|
|
11948
|
+
card.addEventListener('click', () => {
|
|
11949
|
+
body.querySelectorAll('.export-dest-card').forEach(c => c.classList.remove('selected'));
|
|
11950
|
+
card.classList.add('selected');
|
|
11951
|
+
exportState.destination.type = card.dataset.dest;
|
|
11952
|
+
const notionArea = document.getElementById('notionConfigArea');
|
|
11953
|
+
if (notionArea) notionArea.style.display = card.dataset.dest === 'notion' ? 'block' : 'none';
|
|
11954
|
+
});
|
|
11955
|
+
});
|
|
11956
|
+
|
|
11957
|
+
// Check Notion connection
|
|
11958
|
+
if (exportState.destination.type === 'notion') {
|
|
11959
|
+
await loadNotionStatus();
|
|
11960
|
+
}
|
|
11961
|
+
|
|
11962
|
+
async function loadNotionStatus() {
|
|
11963
|
+
const statusArea = document.getElementById('notionStatusArea');
|
|
11964
|
+
const tokenArea = document.getElementById('notionTokenArea');
|
|
11965
|
+
const pagePickerArea = document.getElementById('notionPagePickerArea');
|
|
11966
|
+
|
|
11967
|
+
try {
|
|
11968
|
+
const resp = await fetch('/api/notion/status');
|
|
11969
|
+
const status = await resp.json();
|
|
11970
|
+
|
|
11971
|
+
if (status.connected) {
|
|
11972
|
+
statusArea.innerHTML = `
|
|
11973
|
+
<div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--bg-tertiary); border-radius: 20px; font-size: 11px;">
|
|
11974
|
+
<div style="width: 6px; height: 6px; border-radius: 50%; background: var(--success, #22c55e);"></div>
|
|
11975
|
+
Connected as ${status.name || 'Integration'}
|
|
11976
|
+
<button style="background: none; border: none; color: var(--accent); font-size: 10px; cursor: pointer; padding: 0;" id="notionChangeTokenBtn">Change</button>
|
|
11977
|
+
</div>
|
|
11978
|
+
`;
|
|
11979
|
+
tokenArea.style.display = 'none';
|
|
11980
|
+
pagePickerArea.style.display = 'block';
|
|
11981
|
+
|
|
11982
|
+
document.getElementById('notionChangeTokenBtn')?.addEventListener('click', () => {
|
|
11983
|
+
tokenArea.style.display = 'block';
|
|
11984
|
+
});
|
|
11985
|
+
|
|
11986
|
+
// Load pages
|
|
11987
|
+
await searchNotionPages('');
|
|
11988
|
+
} else {
|
|
11989
|
+
statusArea.innerHTML = `
|
|
11990
|
+
<div style="display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--bg-tertiary); border-radius: 20px; font-size: 11px; color: var(--text-secondary);">
|
|
11991
|
+
<div style="width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted);"></div>
|
|
11992
|
+
Not connected
|
|
11993
|
+
</div>
|
|
11994
|
+
`;
|
|
11995
|
+
tokenArea.style.display = 'block';
|
|
11996
|
+
pagePickerArea.style.display = 'none';
|
|
11997
|
+
}
|
|
11998
|
+
} catch {
|
|
11999
|
+
statusArea.innerHTML = '<span style="font-size: 11px; color: var(--error);">Failed to check status</span>';
|
|
12000
|
+
tokenArea.style.display = 'block';
|
|
12001
|
+
}
|
|
12002
|
+
}
|
|
12003
|
+
|
|
12004
|
+
// Save token
|
|
12005
|
+
document.getElementById('notionSaveTokenBtn')?.addEventListener('click', async () => {
|
|
12006
|
+
const input = document.getElementById('notionTokenInput');
|
|
12007
|
+
const token = input?.value?.trim();
|
|
12008
|
+
if (!token) return;
|
|
12009
|
+
|
|
12010
|
+
const btn = document.getElementById('notionSaveTokenBtn');
|
|
12011
|
+
btn.textContent = 'Connecting...';
|
|
12012
|
+
btn.disabled = true;
|
|
12013
|
+
|
|
12014
|
+
try {
|
|
12015
|
+
await fetch('/api/settings/notion', {
|
|
12016
|
+
method: 'PUT',
|
|
12017
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12018
|
+
body: JSON.stringify({ apiToken: token }),
|
|
12019
|
+
});
|
|
12020
|
+
await loadNotionStatus();
|
|
12021
|
+
showToast('Notion connected', 'success');
|
|
12022
|
+
} catch {
|
|
12023
|
+
showToast('Failed to connect', 'error');
|
|
12024
|
+
} finally {
|
|
12025
|
+
btn.textContent = 'Connect';
|
|
12026
|
+
btn.disabled = false;
|
|
12027
|
+
}
|
|
12028
|
+
});
|
|
12029
|
+
|
|
12030
|
+
// Page search
|
|
12031
|
+
let searchDebounce;
|
|
12032
|
+
document.getElementById('notionPageSearch')?.addEventListener('input', (e) => {
|
|
12033
|
+
clearTimeout(searchDebounce);
|
|
12034
|
+
searchDebounce = setTimeout(() => searchNotionPages(e.target.value), 300);
|
|
12035
|
+
});
|
|
12036
|
+
|
|
12037
|
+
async function searchNotionPages(query) {
|
|
12038
|
+
const listEl = document.getElementById('notionPageList');
|
|
12039
|
+
if (!listEl) return;
|
|
12040
|
+
listEl.innerHTML = '<div style="padding: 8px; font-size: 11px; color: var(--text-muted);">Searching...</div>';
|
|
12041
|
+
|
|
12042
|
+
try {
|
|
12043
|
+
const resp = await fetch(`/api/notion/search?q=${encodeURIComponent(query)}`);
|
|
12044
|
+
const data = await resp.json();
|
|
12045
|
+
|
|
12046
|
+
if (!data.success || !data.pages?.length) {
|
|
12047
|
+
listEl.innerHTML = '<div style="padding: 8px; font-size: 11px; color: var(--text-muted);">No pages found. Make sure to share pages with your integration.</div>';
|
|
12048
|
+
return;
|
|
12049
|
+
}
|
|
12050
|
+
|
|
12051
|
+
const selectedId = exportState.destination.config?.parentPageId;
|
|
12052
|
+
listEl.innerHTML = data.pages.map(p => `
|
|
12053
|
+
<div class="notion-page-item" data-page-id="${p.id}" data-page-title="${(p.title || '').replace(/"/g, '"')}"
|
|
12054
|
+
style="padding: 8px 10px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 12px;
|
|
12055
|
+
border-bottom: 1px solid var(--border); transition: background 0.15s;
|
|
12056
|
+
${p.id === selectedId ? 'background: color-mix(in srgb, var(--accent) 15%, transparent);' : ''}">
|
|
12057
|
+
<span style="font-size: 14px;">${p.icon || '\u{1F4C4}'}</span>
|
|
12058
|
+
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${p.title}</span>
|
|
12059
|
+
${p.id === selectedId ? '<span style="margin-left: auto; font-size: 10px; color: var(--accent);">Selected</span>' : ''}
|
|
12060
|
+
</div>
|
|
12061
|
+
`).join('');
|
|
12062
|
+
|
|
12063
|
+
listEl.querySelectorAll('.notion-page-item').forEach(item => {
|
|
12064
|
+
item.addEventListener('click', () => {
|
|
12065
|
+
const pageId = item.dataset.pageId;
|
|
12066
|
+
const pageTitle = item.dataset.pageTitle;
|
|
12067
|
+
exportState.destination.config = {
|
|
12068
|
+
...exportState.destination.config,
|
|
12069
|
+
parentPageId: pageId,
|
|
12070
|
+
template: 'marketing',
|
|
12071
|
+
};
|
|
12072
|
+
|
|
12073
|
+
// Visual feedback
|
|
12074
|
+
listEl.querySelectorAll('.notion-page-item').forEach(i => {
|
|
12075
|
+
i.style.background = '';
|
|
12076
|
+
i.querySelector('span:last-child')?.remove();
|
|
12077
|
+
});
|
|
12078
|
+
item.style.background = 'color-mix(in srgb, var(--accent) 15%, transparent)';
|
|
12079
|
+
const badge = document.createElement('span');
|
|
12080
|
+
badge.style.cssText = 'margin-left: auto; font-size: 10px; color: var(--accent);';
|
|
12081
|
+
badge.textContent = 'Selected';
|
|
12082
|
+
item.appendChild(badge);
|
|
12083
|
+
|
|
12084
|
+
// Persist selection
|
|
12085
|
+
fetch('/api/settings/notion', {
|
|
12086
|
+
method: 'PUT',
|
|
12087
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12088
|
+
body: JSON.stringify({ lastParentPageId: pageId, lastParentPageTitle: pageTitle }),
|
|
12089
|
+
}).catch(() => {});
|
|
12090
|
+
});
|
|
12091
|
+
|
|
12092
|
+
// Hover effect
|
|
12093
|
+
item.addEventListener('mouseenter', () => {
|
|
12094
|
+
if (item.dataset.pageId !== exportState.destination.config?.parentPageId) {
|
|
12095
|
+
item.style.background = 'var(--bg-tertiary)';
|
|
12096
|
+
}
|
|
12097
|
+
});
|
|
12098
|
+
item.addEventListener('mouseleave', () => {
|
|
12099
|
+
if (item.dataset.pageId !== exportState.destination.config?.parentPageId) {
|
|
12100
|
+
item.style.background = '';
|
|
12101
|
+
}
|
|
12102
|
+
});
|
|
12103
|
+
});
|
|
12104
|
+
} catch (err) {
|
|
12105
|
+
listEl.innerHTML = `<div style="padding: 8px; font-size: 11px; color: var(--error);">${err.message || 'Search failed'}</div>`;
|
|
12106
|
+
}
|
|
12107
|
+
}
|
|
12108
|
+
}
|
|
12109
|
+
|
|
12110
|
+
async function executeExport() {
|
|
12111
|
+
const nextBtn = document.getElementById('exportNextBtn');
|
|
12112
|
+
nextBtn.textContent = 'Exporting...';
|
|
12113
|
+
nextBtn.disabled = true;
|
|
12114
|
+
|
|
12115
|
+
const progressArea = document.getElementById('exportProgressArea');
|
|
12116
|
+
const progressFill = document.getElementById('exportProgressFill');
|
|
12117
|
+
const progressText = document.getElementById('exportProgressText');
|
|
12118
|
+
if (progressArea) progressArea.style.display = '';
|
|
12119
|
+
|
|
12120
|
+
const updateProgress = (pct, text) => {
|
|
12121
|
+
if (progressFill) progressFill.style.width = `${pct}%`;
|
|
12122
|
+
if (progressText) progressText.textContent = text || '';
|
|
12123
|
+
};
|
|
12124
|
+
|
|
12125
|
+
// Listen for WebSocket progress
|
|
12126
|
+
const originalWsHandler = window._batchExportProgressHandler;
|
|
12127
|
+
window._batchExportProgressHandler = (data) => updateProgress(data.percent || 0, data.detail || '');
|
|
12128
|
+
|
|
12129
|
+
try {
|
|
12130
|
+
const isNotion = exportState.destination.type === 'notion';
|
|
12131
|
+
const docs = exportState.documents || {};
|
|
12132
|
+
const hasDocuments = Object.keys(docs).length > 0;
|
|
12133
|
+
const parentPageId = exportState.destination.config?.parentPageId;
|
|
12134
|
+
|
|
12135
|
+
if (isNotion && hasDocuments && parentPageId) {
|
|
12136
|
+
// Use new Notion API document export
|
|
12137
|
+
let successCount = 0;
|
|
12138
|
+
const totalProjects = projectDetails.length;
|
|
12139
|
+
|
|
12140
|
+
for (let i = 0; i < totalProjects; i++) {
|
|
12141
|
+
const p = projectDetails[i];
|
|
12142
|
+
updateProgress(Math.round((i / totalProjects) * 100), `Creating page ${i + 1}/${totalProjects}: ${p.marketingTitle || p.name}`);
|
|
12143
|
+
|
|
12144
|
+
// Use the per-project document (already customized in compose step)
|
|
12145
|
+
const doc = docs[p.id] || exportState.document;
|
|
12146
|
+
if (!doc) continue;
|
|
12147
|
+
|
|
12148
|
+
// Update title/description from step 1
|
|
12149
|
+
doc.title = exportState.projects[i]?.title || doc.title;
|
|
12150
|
+
const descSection = doc.sections.find(s => s.type === 'paragraph');
|
|
12151
|
+
if (descSection) descSection.text = exportState.projects[i]?.description || descSection.text;
|
|
12152
|
+
|
|
12153
|
+
const resp = await fetch('/api/export/notion-page', {
|
|
12154
|
+
method: 'POST',
|
|
12155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12156
|
+
body: JSON.stringify({ document: doc, parentPageId }),
|
|
12157
|
+
});
|
|
12158
|
+
const result = await resp.json();
|
|
12159
|
+
|
|
12160
|
+
if (result.success) {
|
|
12161
|
+
successCount++;
|
|
12162
|
+
if (result.pageUrl && totalProjects === 1) {
|
|
12163
|
+
window.open(result.pageUrl, '_blank');
|
|
12164
|
+
}
|
|
12165
|
+
}
|
|
12166
|
+
}
|
|
12167
|
+
|
|
12168
|
+
updateProgress(100, 'Done');
|
|
12169
|
+
if (successCount > 0) {
|
|
12170
|
+
showToast(`Created ${successCount} Notion page${successCount > 1 ? 's' : ''}`, 'success');
|
|
12171
|
+
document.getElementById('batchExportModal')?.remove();
|
|
12172
|
+
exitSelectMode();
|
|
12173
|
+
loadProjects();
|
|
12174
|
+
} else {
|
|
12175
|
+
showToast('Export failed', 'error');
|
|
12176
|
+
}
|
|
12177
|
+
} else {
|
|
12178
|
+
// Fallback to batch pipeline for non-Notion or no document
|
|
12179
|
+
const resp = await fetch('/api/export/batch', {
|
|
12180
|
+
method: 'POST',
|
|
12181
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12182
|
+
body: JSON.stringify(exportState)
|
|
12183
|
+
});
|
|
12184
|
+
const result = await resp.json();
|
|
12185
|
+
|
|
12186
|
+
if (result.success || (result.results && result.results.some(r => r.success))) {
|
|
12187
|
+
const successCount = result.results?.filter(r => r.success).length || 0;
|
|
12188
|
+
showToast(`Exported ${successCount} project${successCount > 1 ? 's' : ''} successfully`, 'success');
|
|
12189
|
+
const urls = (result.results || []).filter(r => r.destinationUrl).map(r => r.destinationUrl);
|
|
12190
|
+
if (urls.length === 1) window.open(urls[0], '_blank');
|
|
12191
|
+
document.getElementById('batchExportModal')?.remove();
|
|
12192
|
+
exitSelectMode();
|
|
12193
|
+
loadProjects();
|
|
12194
|
+
} else {
|
|
12195
|
+
showToast(result.errors?.join(', ') || result.error || 'Export failed', 'error');
|
|
12196
|
+
}
|
|
12197
|
+
}
|
|
12198
|
+
} catch (err) {
|
|
12199
|
+
showToast('Export failed: ' + (err.message || 'Unknown error'), 'error');
|
|
12200
|
+
} finally {
|
|
12201
|
+
nextBtn.textContent = 'Export';
|
|
12202
|
+
nextBtn.disabled = false;
|
|
12203
|
+
window._batchExportProgressHandler = originalWsHandler;
|
|
12204
|
+
}
|
|
12205
|
+
}
|
|
12206
|
+
|
|
12207
|
+
// Navigation
|
|
12208
|
+
document.getElementById('exportNextBtn').addEventListener('click', () => {
|
|
12209
|
+
if (currentStep < 3) {
|
|
12210
|
+
renderStep(currentStep + 1);
|
|
12211
|
+
} else {
|
|
12212
|
+
executeExport();
|
|
12213
|
+
}
|
|
12214
|
+
});
|
|
12215
|
+
|
|
12216
|
+
document.getElementById('exportPrevBtn').addEventListener('click', () => {
|
|
12217
|
+
if (currentStep > 1) renderStep(currentStep - 1);
|
|
12218
|
+
});
|
|
12219
|
+
|
|
12220
|
+
// Click on stepper to navigate
|
|
12221
|
+
modal.querySelectorAll('.export-step').forEach(s => {
|
|
12222
|
+
s.addEventListener('click', () => {
|
|
12223
|
+
const step = parseInt(s.dataset.step);
|
|
12224
|
+
if (step <= currentStep + 1) renderStep(step);
|
|
12225
|
+
});
|
|
12226
|
+
});
|
|
12227
|
+
|
|
12228
|
+
// Initialize
|
|
12229
|
+
renderStep(1);
|
|
12230
|
+
}
|
|
12231
|
+
|
|
11420
12232
|
document.getElementById('deleteSelectedBtn').addEventListener('click', async () => {
|
|
11421
12233
|
if (selectedProjects.size === 0) return;
|
|
11422
12234
|
|
|
@@ -11448,6 +12260,11 @@
|
|
|
11448
12260
|
document.getElementById('refreshProjects').addEventListener('click', loadProjects);
|
|
11449
12261
|
document.getElementById('importProjectsBtn')?.addEventListener('click', openImportPicker);
|
|
11450
12262
|
|
|
12263
|
+
document.getElementById('exportSelectedBtn').addEventListener('click', () => {
|
|
12264
|
+
if (selectedProjects.size === 0) return;
|
|
12265
|
+
openBatchExportModal([...selectedProjects]);
|
|
12266
|
+
});
|
|
12267
|
+
|
|
11451
12268
|
// Delete all projects
|
|
11452
12269
|
document.getElementById('deleteAllProjects').addEventListener('click', async () => {
|
|
11453
12270
|
if (projects.length === 0) {
|
|
@@ -11576,7 +12393,12 @@
|
|
|
11576
12393
|
<div class="analysis-grid">
|
|
11577
12394
|
<!-- Project Info Card -->
|
|
11578
12395
|
<div class="card analysis-info-card">
|
|
11579
|
-
<div class="card-title"
|
|
12396
|
+
<div class="card-title" id="projectTitleEditable" style="cursor: pointer; display: flex; align-items: center; gap: 6px;" title="Click to edit">
|
|
12397
|
+
<span id="projectTitleText">${currentProject.name}</span>
|
|
12398
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.3; flex-shrink: 0;">
|
|
12399
|
+
<path d="M17 3a2.85 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5z"/>
|
|
12400
|
+
</svg>
|
|
12401
|
+
</div>
|
|
11580
12402
|
<div class="analysis-stats">
|
|
11581
12403
|
${hasVideo ? `<span class="stat"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> Video</span>` : ''}
|
|
11582
12404
|
${hasFrames ? `<span class="stat"><strong>${currentProject.frames.length}</strong> screenshots</span>` : ''}
|
|
@@ -11760,6 +12582,47 @@
|
|
|
11760
12582
|
</div>
|
|
11761
12583
|
` : ''}
|
|
11762
12584
|
|
|
12585
|
+
${(currentProject.status === 'analyzed' || currentProject.aiSummary) && (currentProject.frameCount > 0 || (currentProject.frames && currentProject.frames.length > 0)) ? `
|
|
12586
|
+
<div class="analysis-section">
|
|
12587
|
+
<div class="analysis-section-title" style="display: flex; align-items: center; justify-content: space-between;">
|
|
12588
|
+
<span>Interactive Visualizations</span>
|
|
12589
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
12590
|
+
<select id="vizTemplateSelect" style="font-size: 11px; padding: 3px 8px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 4px; color: var(--text-primary);">
|
|
12591
|
+
<option value="flow-diagram">Flow Diagram</option>
|
|
12592
|
+
<option value="device-showcase">Device Showcase</option>
|
|
12593
|
+
<option value="metrics-dashboard">Metrics Dashboard</option>
|
|
12594
|
+
<option value="app-flow-map">App Flow Map</option>
|
|
12595
|
+
</select>
|
|
12596
|
+
<button class="icon-btn" id="vizDownloadInlineBtn" title="Download PNG" style="width: 28px; height: 28px; font-size: 9px; font-weight: 600;">
|
|
12597
|
+
PNG
|
|
12598
|
+
</button>
|
|
12599
|
+
<button class="icon-btn" id="vizDownloadGifInlineBtn" title="Download GIF (animated)" style="width: 28px; height: 28px; font-size: 9px; font-weight: 600;">
|
|
12600
|
+
GIF
|
|
12601
|
+
</button>
|
|
12602
|
+
<button class="icon-btn" id="vizFullscreenBtn" title="Fullscreen" style="width: 28px; height: 28px;">
|
|
12603
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
12604
|
+
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>
|
|
12605
|
+
</svg>
|
|
12606
|
+
</button>
|
|
12607
|
+
</div>
|
|
12608
|
+
</div>
|
|
12609
|
+
<div id="vizContainer" style="border-radius: 12px; overflow: hidden; border: 1px solid var(--border); margin-top: 8px; position: relative;">
|
|
12610
|
+
<div id="vizLoadingOverlay" style="position: absolute; inset: 0; background: #0f0f23; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 2; transition: opacity 0.3s;">
|
|
12611
|
+
<div style="width: 200px; height: 6px; background: rgba(255,255,255,0.06); border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
|
12612
|
+
<div id="vizLoadingBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #6366f1, #818cf8); border-radius: 3px; transition: width 0.5s;"></div>
|
|
12613
|
+
</div>
|
|
12614
|
+
<div id="vizLoadingText" style="font-size: 11px; color: rgba(255,255,255,0.35);">Loading visualization...</div>
|
|
12615
|
+
<div id="vizLoadingModel" style="font-size: 9px; color: rgba(255,255,255,0.2); margin-top: 4px;"></div>
|
|
12616
|
+
</div>
|
|
12617
|
+
<iframe id="vizIframe"
|
|
12618
|
+
src="/api/visualization/${currentProject.id}/flow-diagram"
|
|
12619
|
+
style="width: 100%; height: 400px; border: none; background: #0f0f23;"
|
|
12620
|
+
sandbox="allow-scripts allow-same-origin"
|
|
12621
|
+
></iframe>
|
|
12622
|
+
</div>
|
|
12623
|
+
</div>
|
|
12624
|
+
` : ''}
|
|
12625
|
+
|
|
11763
12626
|
${currentProject.ocrText ? `
|
|
11764
12627
|
<div class="analysis-section">
|
|
11765
12628
|
<div class="analysis-section-title">OCR Text</div>
|
|
@@ -11773,6 +12636,166 @@
|
|
|
11773
12636
|
</div>
|
|
11774
12637
|
`;
|
|
11775
12638
|
|
|
12639
|
+
// Setup project title inline edit
|
|
12640
|
+
document.getElementById('projectTitleEditable')?.addEventListener('click', () => {
|
|
12641
|
+
const titleEl = document.getElementById('projectTitleEditable');
|
|
12642
|
+
const textEl = document.getElementById('projectTitleText');
|
|
12643
|
+
if (!titleEl || !textEl || titleEl.querySelector('input')) return;
|
|
12644
|
+
|
|
12645
|
+
const currentName = currentProject.name || '';
|
|
12646
|
+
const pencilSvg = titleEl.querySelector('svg');
|
|
12647
|
+
if (pencilSvg) pencilSvg.style.display = 'none';
|
|
12648
|
+
|
|
12649
|
+
const input = document.createElement('input');
|
|
12650
|
+
input.type = 'text';
|
|
12651
|
+
input.value = currentName;
|
|
12652
|
+
input.style.cssText = 'flex: 1; background: var(--bg-primary); border: 1px solid var(--accent); border-radius: 4px; color: var(--text-primary); font-size: inherit; font-weight: inherit; padding: 2px 6px; outline: none;';
|
|
12653
|
+
textEl.replaceWith(input);
|
|
12654
|
+
input.focus();
|
|
12655
|
+
input.select();
|
|
12656
|
+
|
|
12657
|
+
const save = async () => {
|
|
12658
|
+
const newName = input.value.trim();
|
|
12659
|
+
if (newName && newName !== currentName) {
|
|
12660
|
+
try {
|
|
12661
|
+
await fetch(`/api/projects/${currentProject.id}`, {
|
|
12662
|
+
method: 'PATCH',
|
|
12663
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12664
|
+
body: JSON.stringify({ name: newName, marketingTitle: newName }),
|
|
12665
|
+
});
|
|
12666
|
+
currentProject.name = newName;
|
|
12667
|
+
// Update in projects list too
|
|
12668
|
+
const p = projects.find(pr => pr.id === currentProject.id);
|
|
12669
|
+
if (p) p.name = newName;
|
|
12670
|
+
renderProjects();
|
|
12671
|
+
showToast('Title updated', 'success');
|
|
12672
|
+
} catch { showToast('Failed to update title', 'error'); }
|
|
12673
|
+
}
|
|
12674
|
+
const span = document.createElement('span');
|
|
12675
|
+
span.id = 'projectTitleText';
|
|
12676
|
+
span.textContent = newName || currentName;
|
|
12677
|
+
input.replaceWith(span);
|
|
12678
|
+
if (pencilSvg) pencilSvg.style.display = '';
|
|
12679
|
+
};
|
|
12680
|
+
|
|
12681
|
+
input.addEventListener('blur', save);
|
|
12682
|
+
input.addEventListener('keydown', (e) => {
|
|
12683
|
+
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
12684
|
+
if (e.key === 'Escape') { input.value = currentName; input.blur(); }
|
|
12685
|
+
});
|
|
12686
|
+
});
|
|
12687
|
+
|
|
12688
|
+
// Setup visualization template switcher + fullscreen + loading
|
|
12689
|
+
const vizSelect = document.getElementById('vizTemplateSelect');
|
|
12690
|
+
const vizIframe = document.getElementById('vizIframe');
|
|
12691
|
+
const vizOverlay = document.getElementById('vizLoadingOverlay');
|
|
12692
|
+
const vizBar = document.getElementById('vizLoadingBar');
|
|
12693
|
+
const vizLoadText = document.getElementById('vizLoadingText');
|
|
12694
|
+
const vizLoadModel = document.getElementById('vizLoadingModel');
|
|
12695
|
+
|
|
12696
|
+
function showVizLoading(templateId) {
|
|
12697
|
+
if (!vizOverlay) return;
|
|
12698
|
+
vizOverlay.style.display = 'flex';
|
|
12699
|
+
vizOverlay.style.opacity = '1';
|
|
12700
|
+
if (vizBar) vizBar.style.width = '0%';
|
|
12701
|
+
|
|
12702
|
+
const isAiTemplate = templateId === 'app-flow-map';
|
|
12703
|
+
if (vizLoadText) vizLoadText.textContent = isAiTemplate ? 'AI analyzing flow...' : 'Loading...';
|
|
12704
|
+
|
|
12705
|
+
// Show model name
|
|
12706
|
+
if (vizLoadModel && isAiTemplate) {
|
|
12707
|
+
vizLoadModel.textContent = globalProviderLabel || '';
|
|
12708
|
+
} else if (vizLoadModel) {
|
|
12709
|
+
vizLoadModel.textContent = '';
|
|
12710
|
+
}
|
|
12711
|
+
|
|
12712
|
+
// Animate progress bar
|
|
12713
|
+
let pct = 0;
|
|
12714
|
+
const interval = setInterval(() => {
|
|
12715
|
+
pct += isAiTemplate ? 2 : 8;
|
|
12716
|
+
if (pct > 90) { clearInterval(interval); return; }
|
|
12717
|
+
if (vizBar) vizBar.style.width = pct + '%';
|
|
12718
|
+
if (isAiTemplate && pct > 30 && vizLoadText) vizLoadText.textContent = 'Generating narrative...';
|
|
12719
|
+
if (isAiTemplate && pct > 60 && vizLoadText) vizLoadText.textContent = 'Building flow map...';
|
|
12720
|
+
}, 300);
|
|
12721
|
+
|
|
12722
|
+
vizIframe._loadingInterval = interval;
|
|
12723
|
+
}
|
|
12724
|
+
|
|
12725
|
+
function hideVizLoading() {
|
|
12726
|
+
if (vizIframe?._loadingInterval) clearInterval(vizIframe._loadingInterval);
|
|
12727
|
+
if (vizBar) vizBar.style.width = '100%';
|
|
12728
|
+
setTimeout(() => {
|
|
12729
|
+
if (vizOverlay) { vizOverlay.style.opacity = '0'; setTimeout(() => { vizOverlay.style.display = 'none'; }, 300); }
|
|
12730
|
+
}, 200);
|
|
12731
|
+
}
|
|
12732
|
+
|
|
12733
|
+
if (vizIframe) {
|
|
12734
|
+
vizIframe.addEventListener('load', hideVizLoading);
|
|
12735
|
+
showVizLoading('flow-diagram');
|
|
12736
|
+
}
|
|
12737
|
+
|
|
12738
|
+
if (vizSelect && vizIframe) {
|
|
12739
|
+
vizSelect.addEventListener('change', () => {
|
|
12740
|
+
showVizLoading(vizSelect.value);
|
|
12741
|
+
vizIframe.src = `/api/visualization/${currentProject.id}/${vizSelect.value}`;
|
|
12742
|
+
});
|
|
12743
|
+
}
|
|
12744
|
+
document.getElementById('vizFullscreenBtn')?.addEventListener('click', () => {
|
|
12745
|
+
openVizFullscreen(currentProject.id, vizSelect?.value || 'flow-diagram');
|
|
12746
|
+
});
|
|
12747
|
+
document.getElementById('vizDownloadInlineBtn')?.addEventListener('click', async () => {
|
|
12748
|
+
const btn = document.getElementById('vizDownloadInlineBtn');
|
|
12749
|
+
btn.style.opacity = '0.4';
|
|
12750
|
+
btn.disabled = true;
|
|
12751
|
+
try {
|
|
12752
|
+
const resp = await fetch('/api/visualization/screenshot', {
|
|
12753
|
+
method: 'POST',
|
|
12754
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12755
|
+
body: JSON.stringify({ projectId: currentProject.id, templateId: vizSelect?.value || 'flow-diagram', format: 'png' }),
|
|
12756
|
+
});
|
|
12757
|
+
const data = await resp.json();
|
|
12758
|
+
if (data.success && data.downloadUrl) {
|
|
12759
|
+
const a = document.createElement('a');
|
|
12760
|
+
a.href = data.downloadUrl;
|
|
12761
|
+
a.download = `viz-${vizSelect?.value || 'flow-diagram'}.png`;
|
|
12762
|
+
document.body.appendChild(a);
|
|
12763
|
+
a.click();
|
|
12764
|
+
a.remove();
|
|
12765
|
+
showToast('PNG downloaded', 'success');
|
|
12766
|
+
} else {
|
|
12767
|
+
showToast(data.error || 'Screenshot failed', 'error');
|
|
12768
|
+
}
|
|
12769
|
+
} catch { showToast('Download failed', 'error'); }
|
|
12770
|
+
finally { btn.style.opacity = ''; btn.disabled = false; }
|
|
12771
|
+
});
|
|
12772
|
+
document.getElementById('vizDownloadGifInlineBtn')?.addEventListener('click', async () => {
|
|
12773
|
+
const btn = document.getElementById('vizDownloadGifInlineBtn');
|
|
12774
|
+
btn.textContent = '...';
|
|
12775
|
+
btn.style.opacity = '0.4';
|
|
12776
|
+
btn.disabled = true;
|
|
12777
|
+
try {
|
|
12778
|
+
const resp = await fetch('/api/visualization/screenshot', {
|
|
12779
|
+
method: 'POST',
|
|
12780
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12781
|
+
body: JSON.stringify({ projectId: currentProject.id, templateId: vizSelect?.value || 'flow-diagram', format: 'gif' }),
|
|
12782
|
+
});
|
|
12783
|
+
const data = await resp.json();
|
|
12784
|
+
if (data.success && data.downloadUrl) {
|
|
12785
|
+
const a = document.createElement('a');
|
|
12786
|
+
a.href = data.downloadUrl;
|
|
12787
|
+
a.download = `viz-${vizSelect?.value || 'flow-diagram'}.${data.format || 'gif'}`;
|
|
12788
|
+
document.body.appendChild(a);
|
|
12789
|
+
a.click();
|
|
12790
|
+
a.remove();
|
|
12791
|
+
showToast(`${(data.format || 'gif').toUpperCase()} downloaded`, 'success');
|
|
12792
|
+
} else {
|
|
12793
|
+
showToast(data.error || 'GIF capture failed', 'error');
|
|
12794
|
+
}
|
|
12795
|
+
} catch { showToast('Download failed', 'error'); }
|
|
12796
|
+
finally { btn.textContent = 'GIF'; btn.style.opacity = ''; btn.disabled = false; }
|
|
12797
|
+
});
|
|
12798
|
+
|
|
11776
12799
|
// Setup video drag and drop after DOM update
|
|
11777
12800
|
setTimeout(setupVideoDragAndDrop, 0);
|
|
11778
12801
|
|
|
@@ -12646,6 +13669,108 @@
|
|
|
12646
13669
|
});
|
|
12647
13670
|
}
|
|
12648
13671
|
|
|
13672
|
+
function openVizFullscreen(projectId, templateId) {
|
|
13673
|
+
const existing = document.getElementById('vizFullscreenOverlay');
|
|
13674
|
+
if (existing) existing.remove();
|
|
13675
|
+
|
|
13676
|
+
const overlay = document.createElement('div');
|
|
13677
|
+
overlay.id = 'vizFullscreenOverlay';
|
|
13678
|
+
overlay.style.cssText = 'position: fixed; inset: 0; z-index: 10000; background: #0f0f23; display: flex; flex-direction: column;';
|
|
13679
|
+
overlay.innerHTML = `
|
|
13680
|
+
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.08);">
|
|
13681
|
+
<select id="vizFsTemplateSelect" style="font-size: 12px; padding: 5px 10px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: #fff;">
|
|
13682
|
+
<option value="flow-diagram" ${templateId === 'flow-diagram' ? 'selected' : ''}>Flow Diagram</option>
|
|
13683
|
+
<option value="device-showcase" ${templateId === 'device-showcase' ? 'selected' : ''}>Device Showcase</option>
|
|
13684
|
+
<option value="metrics-dashboard" ${templateId === 'metrics-dashboard' ? 'selected' : ''}>Metrics Dashboard</option>
|
|
13685
|
+
<option value="app-flow-map" ${templateId === 'app-flow-map' ? 'selected' : ''}>App Flow Map</option>
|
|
13686
|
+
</select>
|
|
13687
|
+
<div style="display: flex; align-items: center; gap: 4px;">
|
|
13688
|
+
<button id="vizFsDownloadPng" title="Download PNG" style="background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); cursor: pointer; padding: 5px 10px; border-radius: 6px; font-size: 11px; display: flex; align-items: center; gap: 4px;">
|
|
13689
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
13690
|
+
PNG
|
|
13691
|
+
</button>
|
|
13692
|
+
<button id="vizFsDownloadGif" title="Download GIF (animated)" style="background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.7); cursor: pointer; padding: 5px 10px; border-radius: 6px; font-size: 11px; display: flex; align-items: center; gap: 4px;">
|
|
13693
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
13694
|
+
GIF
|
|
13695
|
+
</button>
|
|
13696
|
+
<button style="background: none; border: none; color: rgba(255,255,255,0.6); cursor: pointer; padding: 6px; border-radius: 6px; display: flex; align-items: center;" id="vizFsCloseBtn" title="Close (Esc)">
|
|
13697
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
13698
|
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
13699
|
+
</svg>
|
|
13700
|
+
</button>
|
|
13701
|
+
</div>
|
|
13702
|
+
</div>
|
|
13703
|
+
<iframe id="vizFsIframe"
|
|
13704
|
+
src="/api/visualization/${projectId}/${templateId}"
|
|
13705
|
+
style="flex: 1; width: 100%; border: none; background: #0f0f23;"
|
|
13706
|
+
sandbox="allow-scripts allow-same-origin"
|
|
13707
|
+
></iframe>
|
|
13708
|
+
`;
|
|
13709
|
+
document.body.appendChild(overlay);
|
|
13710
|
+
|
|
13711
|
+
const fsIframe = document.getElementById('vizFsIframe');
|
|
13712
|
+
const fsSelect = document.getElementById('vizFsTemplateSelect');
|
|
13713
|
+
|
|
13714
|
+
fsSelect.addEventListener('change', () => {
|
|
13715
|
+
fsIframe.src = `/api/visualization/${projectId}/${fsSelect.value}`;
|
|
13716
|
+
// Sync back to inline selector
|
|
13717
|
+
const inlineSelect = document.getElementById('vizTemplateSelect');
|
|
13718
|
+
if (inlineSelect) inlineSelect.value = fsSelect.value;
|
|
13719
|
+
const inlineIframe = document.getElementById('vizIframe');
|
|
13720
|
+
if (inlineIframe) inlineIframe.src = fsIframe.src;
|
|
13721
|
+
});
|
|
13722
|
+
|
|
13723
|
+
document.getElementById('vizFsCloseBtn').addEventListener('click', () => overlay.remove());
|
|
13724
|
+
|
|
13725
|
+
// Download handlers
|
|
13726
|
+
async function downloadViz(fmt) {
|
|
13727
|
+
const btn = document.getElementById(fmt === 'gif' ? 'vizFsDownloadGif' : 'vizFsDownloadPng');
|
|
13728
|
+
const origText = btn.textContent;
|
|
13729
|
+
btn.textContent = fmt === 'gif' ? 'Capturing...' : 'Capturing...';
|
|
13730
|
+
btn.disabled = true;
|
|
13731
|
+
btn.style.opacity = '0.5';
|
|
13732
|
+
|
|
13733
|
+
try {
|
|
13734
|
+
const resp = await fetch('/api/visualization/screenshot', {
|
|
13735
|
+
method: 'POST',
|
|
13736
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13737
|
+
body: JSON.stringify({
|
|
13738
|
+
projectId,
|
|
13739
|
+
templateId: fsSelect.value,
|
|
13740
|
+
format: fmt,
|
|
13741
|
+
}),
|
|
13742
|
+
});
|
|
13743
|
+
const data = await resp.json();
|
|
13744
|
+
if (data.success && data.downloadUrl) {
|
|
13745
|
+
// Trigger download
|
|
13746
|
+
const a = document.createElement('a');
|
|
13747
|
+
a.href = data.downloadUrl;
|
|
13748
|
+
a.download = `viz-${fsSelect.value}.${data.format || fmt}`;
|
|
13749
|
+
document.body.appendChild(a);
|
|
13750
|
+
a.click();
|
|
13751
|
+
a.remove();
|
|
13752
|
+
showToast(`${(data.format || fmt).toUpperCase()} downloaded`, 'success');
|
|
13753
|
+
} else {
|
|
13754
|
+
showToast(data.error || 'Screenshot failed', 'error');
|
|
13755
|
+
}
|
|
13756
|
+
} catch (err) {
|
|
13757
|
+
showToast('Download failed: ' + (err.message || 'Unknown error'), 'error');
|
|
13758
|
+
} finally {
|
|
13759
|
+
btn.innerHTML = origText;
|
|
13760
|
+
btn.disabled = false;
|
|
13761
|
+
btn.style.opacity = '';
|
|
13762
|
+
}
|
|
13763
|
+
}
|
|
13764
|
+
|
|
13765
|
+
document.getElementById('vizFsDownloadPng').addEventListener('click', () => downloadViz('png'));
|
|
13766
|
+
document.getElementById('vizFsDownloadGif').addEventListener('click', () => downloadViz('gif'));
|
|
13767
|
+
|
|
13768
|
+
const escHandler = (e) => {
|
|
13769
|
+
if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escHandler); }
|
|
13770
|
+
};
|
|
13771
|
+
document.addEventListener('keydown', escHandler);
|
|
13772
|
+
}
|
|
13773
|
+
|
|
12649
13774
|
async function shareProjectVideo() {
|
|
12650
13775
|
if (!currentProject?.videoPath) {
|
|
12651
13776
|
showToast('No video available', 'error');
|
|
@@ -15555,6 +16680,11 @@
|
|
|
15555
16680
|
case 'templateRenderComplete':
|
|
15556
16681
|
handleTemplateWsMessage(data);
|
|
15557
16682
|
break;
|
|
16683
|
+
case 'batchExportProgress':
|
|
16684
|
+
if (window._batchExportProgressHandler) {
|
|
16685
|
+
window._batchExportProgressHandler(payload);
|
|
16686
|
+
}
|
|
16687
|
+
break;
|
|
15558
16688
|
}
|
|
15559
16689
|
}
|
|
15560
16690
|
|
|
@@ -21139,6 +22269,14 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
|
|
|
21139
22269
|
}
|
|
21140
22270
|
|
|
21141
22271
|
function setupGridEventListeners() {
|
|
22272
|
+
// Back button to return to project
|
|
22273
|
+
document.getElementById('gridBackBtn')?.addEventListener('click', () => {
|
|
22274
|
+
switchView('projects');
|
|
22275
|
+
if (currentProject) {
|
|
22276
|
+
setTimeout(() => selectProject(currentProject.id), 100);
|
|
22277
|
+
}
|
|
22278
|
+
});
|
|
22279
|
+
|
|
21142
22280
|
// Aspect ratio buttons
|
|
21143
22281
|
document.querySelectorAll('.aspect-btn').forEach(btn => {
|
|
21144
22282
|
btn.addEventListener('click', () => {
|
|
@@ -21664,16 +22802,58 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
|
|
|
21664
22802
|
}
|
|
21665
22803
|
previewAbortController = new AbortController();
|
|
21666
22804
|
|
|
21667
|
-
|
|
22805
|
+
const isSmartLayout = ['flow-horizontal', 'flow-vertical', 'infographic'].includes(gridConfig.layout);
|
|
21668
22806
|
const existingImg = canvas.querySelector('.grid-preview-img');
|
|
22807
|
+
|
|
22808
|
+
// Remove any previous loading overlay
|
|
22809
|
+
canvas.querySelector('.grid-loading-overlay')?.remove();
|
|
22810
|
+
|
|
21669
22811
|
if (!existingImg) {
|
|
21670
22812
|
const aspectRatios = { '9:16': 0.5625, '1:1': 1, '16:9': 1.7778 };
|
|
21671
22813
|
const ratio = aspectRatios[gridConfig.aspectRatio] || 0.5625;
|
|
21672
22814
|
const skeletonHeight = Math.round(300 / ratio);
|
|
21673
|
-
canvas.innerHTML =
|
|
22815
|
+
canvas.innerHTML = `
|
|
22816
|
+
<div class="grid-canvas-skeleton" style="width: 300px; height: ${skeletonHeight}px; position: relative;">
|
|
22817
|
+
${isSmartLayout ? `
|
|
22818
|
+
<div style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
|
22819
|
+
<div style="width: 140px; height: 4px; background: rgba(255,255,255,0.06); border-radius: 2px; overflow: hidden; margin-bottom: 8px;">
|
|
22820
|
+
<div class="grid-loading-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #6366f1, #818cf8); border-radius: 2px; transition: width 0.4s;"></div>
|
|
22821
|
+
</div>
|
|
22822
|
+
<div class="grid-loading-text" style="font-size: 10px; color: var(--text-muted);">AI generating layout...</div>
|
|
22823
|
+
<div style="font-size: 9px; color: rgba(255,255,255,0.15); margin-top: 3px;">${globalProviderLabel || ''}</div>
|
|
22824
|
+
</div>
|
|
22825
|
+
` : ''}
|
|
22826
|
+
</div>`;
|
|
21674
22827
|
} else {
|
|
21675
|
-
|
|
21676
|
-
|
|
22828
|
+
if (isSmartLayout) existingImg.style.opacity = '0.5';
|
|
22829
|
+
// Add loading overlay on top of existing image
|
|
22830
|
+
if (isSmartLayout) {
|
|
22831
|
+
const overlay = document.createElement('div');
|
|
22832
|
+
overlay.className = 'grid-loading-overlay';
|
|
22833
|
+
overlay.style.cssText = 'position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0,0,0,0.4); border-radius: 8px; z-index: 2;';
|
|
22834
|
+
overlay.innerHTML = `
|
|
22835
|
+
<div style="width: 140px; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-bottom: 8px;">
|
|
22836
|
+
<div class="grid-loading-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #6366f1, #818cf8); border-radius: 2px; transition: width 0.4s;"></div>
|
|
22837
|
+
</div>
|
|
22838
|
+
<div class="grid-loading-text" style="font-size: 10px; color: rgba(255,255,255,0.7);">AI generating layout...</div>
|
|
22839
|
+
<div style="font-size: 9px; color: rgba(255,255,255,0.3); margin-top: 3px;">${globalProviderLabel || ''}</div>
|
|
22840
|
+
`;
|
|
22841
|
+
canvas.style.position = 'relative';
|
|
22842
|
+
canvas.appendChild(overlay);
|
|
22843
|
+
}
|
|
22844
|
+
}
|
|
22845
|
+
|
|
22846
|
+
// Animate progress bar for smart layouts
|
|
22847
|
+
let gridLoadingInterval;
|
|
22848
|
+
if (isSmartLayout) {
|
|
22849
|
+
let pct = 0;
|
|
22850
|
+
gridLoadingInterval = setInterval(() => {
|
|
22851
|
+
pct += 1.5;
|
|
22852
|
+
if (pct > 90) { clearInterval(gridLoadingInterval); return; }
|
|
22853
|
+
canvas.querySelectorAll('.grid-loading-bar').forEach(b => b.style.width = pct + '%');
|
|
22854
|
+
if (pct > 40) canvas.querySelectorAll('.grid-loading-text').forEach(t => t.textContent = 'Building annotations...');
|
|
22855
|
+
if (pct > 70) canvas.querySelectorAll('.grid-loading-text').forEach(t => t.textContent = 'Composing image...');
|
|
22856
|
+
}, 400);
|
|
21677
22857
|
}
|
|
21678
22858
|
|
|
21679
22859
|
try {
|
|
@@ -21682,8 +22862,9 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
|
|
|
21682
22862
|
method: 'POST',
|
|
21683
22863
|
headers: { 'Content-Type': 'application/json' },
|
|
21684
22864
|
body: JSON.stringify({
|
|
21685
|
-
images: gridImages.map(img => ({ path: img.path })),
|
|
22865
|
+
images: gridImages.map(img => ({ path: img.path, label: img.name })),
|
|
21686
22866
|
config: { ...gridConfig, outputWidth },
|
|
22867
|
+
projectId: currentProject?.id || null,
|
|
21687
22868
|
}),
|
|
21688
22869
|
signal: previewAbortController.signal,
|
|
21689
22870
|
});
|
|
@@ -21694,10 +22875,13 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
|
|
|
21694
22875
|
gridPreviewUrl = data.previewUrl;
|
|
21695
22876
|
const newUrl = `${data.previewUrl}&t=${Date.now()}`;
|
|
21696
22877
|
|
|
22878
|
+
// Clean up loading state
|
|
22879
|
+
if (gridLoadingInterval) clearInterval(gridLoadingInterval);
|
|
22880
|
+
canvas.querySelector('.grid-loading-overlay')?.remove();
|
|
22881
|
+
|
|
21697
22882
|
// Update existing image or create new one
|
|
21698
22883
|
const currentImg = canvas.querySelector('.grid-preview-img');
|
|
21699
22884
|
if (currentImg) {
|
|
21700
|
-
// Preload image then swap
|
|
21701
22885
|
const tempImg = new Image();
|
|
21702
22886
|
tempImg.onload = () => {
|
|
21703
22887
|
currentImg.src = newUrl;
|
|
@@ -21709,6 +22893,10 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
|
|
|
21709
22893
|
}
|
|
21710
22894
|
isFirstPreview = false;
|
|
21711
22895
|
} else {
|
|
22896
|
+
if (gridLoadingInterval) clearInterval(gridLoadingInterval);
|
|
22897
|
+
canvas.querySelector('.grid-loading-overlay')?.remove();
|
|
22898
|
+
const resetImg = canvas.querySelector('.grid-preview-img');
|
|
22899
|
+
if (resetImg) { resetImg.style.opacity = '1'; }
|
|
21712
22900
|
setStatus(data.error || 'Preview failed', 'error');
|
|
21713
22901
|
}
|
|
21714
22902
|
} catch (err) {
|