@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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/chunk-34KRJWZL.js +477 -0
  4. package/dist/chunk-4VNS5WPM.js +42 -0
  5. package/dist/{chunk-FIL7IWEL.js → chunk-DGXAP477.js} +1 -1
  6. package/dist/{chunk-HGWEHWKJ.js → chunk-DKAX5RCX.js} +1 -1
  7. package/dist/{chunk-7EDIUVIO.js → chunk-EU63HPKT.js} +1 -1
  8. package/dist/chunk-QMUEC6B5.js +288 -0
  9. package/dist/{chunk-FNUN7EPB.js → chunk-RCY26WEK.js} +2 -2
  10. package/dist/{chunk-ZLHIHMSL.js → chunk-SWZIBO2R.js} +1 -1
  11. package/dist/chunk-VYYAP5G5.js +265 -0
  12. package/dist/{chunk-VVIOB362.js → chunk-XAMA3JJG.js} +18 -1
  13. package/dist/{chunk-BE7BFMYC.js → chunk-XWBFSSNB.js} +10224 -393
  14. package/dist/{chunk-AHVBE25Y.js → chunk-YNLUOZSZ.js} +274 -667
  15. package/dist/cli.js +33 -31
  16. package/dist/{db-6WLEVKUV.js → db-745LC5YC.js} +2 -2
  17. package/dist/document-AE4XI2CP.js +104 -0
  18. package/dist/{esvp-KVOWYW6G.js → esvp-4LIAU76K.js} +3 -3
  19. package/dist/{esvp-mobile-GZ5EMYPG.js → esvp-mobile-FKFHDS5Q.js} +4 -4
  20. package/dist/frames-RCNLSDD6.js +24 -0
  21. package/dist/{gridCompositor-M3K3LCLZ.js → gridCompositor-VUWBZXYL.js} +262 -3
  22. package/dist/index.d.ts +32 -0
  23. package/dist/index.html +1197 -9
  24. package/dist/index.js +15 -10
  25. package/dist/notion-api-OXSWOJPZ.js +190 -0
  26. package/dist/{ocr-QDYNCSPE.js → ocr-FXRLEP66.js} +1 -1
  27. package/dist/{playwright-VZ7PXDC5.js → playwright-GYKUH34L.js} +3 -3
  28. package/dist/renderer-D22GCMMD.js +17 -0
  29. package/dist/{server-6N3KIEGP.js → server-NTT2XGCC.js} +1 -1
  30. package/dist/server-TKYRIYJ6.js +24 -0
  31. package/dist/{setup-2SQC5UHJ.js → setup-O6WQQAGP.js} +3 -3
  32. package/dist/templates/bundle/bundle.js +4 -2
  33. package/dist/{tools-YGM5HRIB.js → tools-FVVWKEGC.js} +15 -7
  34. package/package.json +2 -2
  35. package/skills/knowledge-brain/SKILL.md +81 -0
  36. package/dist/chunk-MLKGABMK.js +0 -9
  37. 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="deleteSelectedBtn" style="background: var(--error);">Delete Selected</button>
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({ url: startUrl })
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, '&quot;')}" 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, '&quot;')}" 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;">&#9650;</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;">&#9660;</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;">&times;</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, '&quot;')}"
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">${currentProject.name}</div>
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
- // Only show skeleton on first load, otherwise keep current image
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 = `<div class="grid-canvas-skeleton" style="width: 300px; height: ${skeletonHeight}px;"></div>`;
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
- // Add loading indicator to existing image
21676
- existingImg.style.opacity = '0.7';
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) {