kingkont 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/renderer/board.js CHANGED
@@ -38,6 +38,27 @@ window.addEventListener('DOMContentLoaded', async () => {
38
38
  window.appMenu.on('open-settings', () => openSettings());
39
39
  window.appMenu.on('open-updates', () => openUpdatesOverlay());
40
40
  }
41
+ // Подписка на смену Chatium-auth-статуса (login/logout из настроек):
42
+ // - login → чистим localStorage-кэш и заново фетчим /api/projects.
43
+ // Welcome перерисуется со свежими облачными карточками.
44
+ // - logout → чистим кэш + перерисовываем (cloud-карточки исчезнут,
45
+ // fetchListCached не дёрнет /api/projects без логина).
46
+ // setCloudButtonsVisibility пересоберёт sidebar-кнопки (☁ Новый и пр.).
47
+ if (window.appChatium?.onAuthChanged) {
48
+ window.appChatium.onAuthChanged(async (state) => {
49
+ try {
50
+ // Сбрасываем кэш — он содержал данные старого юзера.
51
+ localStorage.removeItem('cloudProjectsCache');
52
+ localStorage.removeItem('cloudProjectsLastOpened');
53
+ // Если открыт welcome (нет проекта) — перерисовать. fetchListCached
54
+ // в renderWelcomeRecents подтянет свежий список с сервера.
55
+ if (!state || !window.cloudProjects) return;
56
+ if (window.cloudProjects.setVisibility) window.cloudProjects.setVisibility();
57
+ if (!document.body.classList.contains('no-project')) return;
58
+ await renderWelcomeRecents();
59
+ } catch (e) { vlog('err', 'auth-changed handler: ' + (e?.message || e)); }
60
+ });
61
+ }
41
62
  // Восстановить состояние панелей таймлайна/превью/реплик
42
63
  const tlOpen = localStorage.getItem('timelineOpen') === '1';
43
64
  const pvOpen = localStorage.getItem('previewOpen') === '1';
@@ -77,20 +98,30 @@ window.addEventListener('DOMContentLoaded', async () => {
77
98
  setInterval(() => { refreshBalance().catch(() => {}); }, 60_000);
78
99
 
79
100
  // Тихий autoload: первый recent с granted-permission (если есть).
80
- try {
81
- const recents = await getRecents();
82
- if (recents.length && recents[0].handle) {
83
- const first = recents[0];
84
- let q = 'prompt';
85
- try { q = await first.handle.queryPermission({ mode: 'readwrite' }); } catch {}
86
- vlog('info', `restore: handle=${first.name} queryPermission=${q}`);
87
- if (q === 'granted') {
88
- await openFilm(first.handle);
89
- return;
101
+ // НО — если юзер только что вышел из проекта (closeProject поставил
102
+ // флаг welcomeOnNextStart), пропускаем autoload и показываем welcome.
103
+ // Это нужно чтобы Cmd+R после явного выхода не реоткрывал проект.
104
+ // (При обычном перезапуске после crash'а флага нет → автоload работает.)
105
+ const skipAutoload = localStorage.getItem('welcomeOnNextStart') === '1';
106
+ if (skipAutoload) {
107
+ localStorage.removeItem('welcomeOnNextStart');
108
+ vlog('info', 'restore: skipped (user explicitly closed last project)');
109
+ } else {
110
+ try {
111
+ const recents = await getRecents();
112
+ if (recents.length && recents[0].handle) {
113
+ const first = recents[0];
114
+ let q = 'prompt';
115
+ try { q = await first.handle.queryPermission({ mode: 'readwrite' }); } catch {}
116
+ vlog('info', `restore: handle=${first.name} queryPermission=${q}`);
117
+ if (q === 'granted') {
118
+ await openFilm(first.handle);
119
+ return;
120
+ }
90
121
  }
122
+ } catch (e) {
123
+ vlog('err', 'restore failed: ' + (e?.message || e));
91
124
  }
92
- } catch (e) {
93
- vlog('err', 'restore failed: ' + (e?.message || e));
94
125
  }
95
126
  await renderWelcomeRecents();
96
127
 
@@ -100,6 +131,14 @@ window.addEventListener('DOMContentLoaded', async () => {
100
131
  };
101
132
  document.getElementById('brandLogo')?.addEventListener('dblclick', openSettingsFromLogo);
102
133
  document.getElementById('welcomeLogo')?.addEventListener('dblclick', openSettingsFromLogo);
134
+ // ПКМ на brand-area (sidebar header) — действия проектного уровня
135
+ // (сохранить как шаблон / обновить шаблон).
136
+ document.querySelector('.brand')?.addEventListener('contextmenu', e => {
137
+ if (!state.filmHandle) return;
138
+ e.preventDefault();
139
+ e.stopPropagation();
140
+ showProjectContextMenu(e.clientX, e.clientY);
141
+ });
103
142
 
104
143
  // Версия приложения на welcome-экране и в шапке проекта (после слова
105
144
  // "KingKont"). appInfo.version() — IPC к main → app.getVersion().
@@ -310,6 +349,11 @@ async function getRecents() {
310
349
 
311
350
  async function touchRecent(handle, thumbBlob) {
312
351
  if (!handle) return;
352
+ // Облачные проекты НЕ попадают в recents — они уже показываются в
353
+ // welcome-grid'е через /api/projects (с ☁-бейджем). Иначе после первого
354
+ // открытия cloud-проект дублируется: один раз как «облачный», второй раз
355
+ // как «локальный недавний» (handle указывает на userData/cloud-projects/<id>).
356
+ if (window.cloudFsShim?.isCloudHandle?.(handle)) return;
313
357
  const list = await _readRecentsMeta();
314
358
  const i = list.findIndex(r => r.name === handle.name);
315
359
  let thumbDataUrl = (i >= 0 ? list[i].thumbDataUrl : null);
@@ -374,70 +418,254 @@ async function renderWelcomeRecents() {
374
418
  openCard.addEventListener('click', () => $('pickRoot').click());
375
419
  grid.appendChild(openCard);
376
420
 
377
- // Title меняем в зависимости от наличия recents.
378
- if (titleEl) titleEl.textContent = list.length ? 'Открыть проект · недавние' : 'Открыть проект';
421
+ // Облачные проекты видна только если залогинен в Chatium.
422
+ // Создаёт серверную запись и открывает её как новый проект.
423
+ let isLoggedIn = false;
424
+ try {
425
+ const s = await window.appSettings?.get();
426
+ isLoggedIn = !!(s?.useChatium && s?.chatium?.token);
427
+ } catch {}
428
+ if (isLoggedIn) {
429
+ const cloudCard = document.createElement('div');
430
+ cloudCard.className = 'welcome-card open-card';
431
+ const cloudThumb = document.createElement('div');
432
+ cloudThumb.className = 'welcome-card-thumb';
433
+ cloudThumb.textContent = '☁';
434
+ const cloudMeta = document.createElement('div');
435
+ cloudMeta.className = 'welcome-card-meta';
436
+ const cloudName = document.createElement('div');
437
+ cloudName.className = 'welcome-card-name';
438
+ cloudName.textContent = 'Новый проект (облако)';
439
+ const cloudSub = document.createElement('div');
440
+ cloudSub.className = 'welcome-card-ts';
441
+ cloudSub.textContent = 'хранится на сервере';
442
+ cloudMeta.append(cloudName, cloudSub);
443
+ cloudCard.append(cloudThumb, cloudMeta);
444
+ cloudCard.addEventListener('click', () => {
445
+ if (window.cloudProjects?.createNew) window.cloudProjects.createNew();
446
+ });
447
+ grid.appendChild(cloudCard);
448
+ // (отдельная карточка «Мои облачные проекты» убрана — облачные теперь
449
+ // показываются inline в общем списке с ☁-бейджем.)
450
+ }
451
+
452
+ // Сразу за «Открыть проект» — карточка «Шаблоны» (открывает библиотеку
453
+ // шаблонов с серверa). Стиль такой же как у open-card — иконка + meta.
454
+ const tplCard = document.createElement('div');
455
+ tplCard.className = 'welcome-card open-card';
456
+ const tplThumb = document.createElement('div');
457
+ tplThumb.className = 'welcome-card-thumb';
458
+ // Своя картинка-обложка для карточки «Шаблоны» (assets/templates.jpg).
459
+ // Если не загрузится — fallback на 🎨.
460
+ const tplThumbImg = document.createElement('img');
461
+ tplThumbImg.src = 'assets/templates.jpg';
462
+ tplThumbImg.alt = '';
463
+ tplThumbImg.draggable = false;
464
+ tplThumbImg.onerror = () => { tplThumb.textContent = '🎨'; tplThumbImg.remove(); };
465
+ tplThumb.appendChild(tplThumbImg);
466
+ const tplMeta = document.createElement('div');
467
+ tplMeta.className = 'welcome-card-meta';
468
+ const tplName = document.createElement('div');
469
+ tplName.className = 'welcome-card-name';
470
+ tplName.textContent = 'Шаблоны';
471
+ const tplSub = document.createElement('div');
472
+ tplSub.className = 'welcome-card-ts';
473
+ tplSub.textContent = 'библиотека на сервере';
474
+ tplMeta.append(tplName, tplSub);
475
+ tplCard.append(tplThumb, tplMeta);
476
+ tplCard.addEventListener('click', () => {
477
+ if (typeof openTemplatesWindow === 'function') openTemplatesWindow();
478
+ });
479
+ grid.appendChild(tplCard);
480
+
481
+ // ---- Облачные проекты: добавляем в тот же grid с ☁ бейджем ----
482
+ // Cached → рендерим сразу, fresh → background → merge через onListChange.
483
+ let cloudItems = [];
484
+ if (isLoggedIn && window.cloudProjects?.fetchListCached) {
485
+ const { cached, promise } = window.cloudProjects.fetchListCached();
486
+ cloudItems = cached;
487
+ // Когда фон-фетч обновит — перерендерим карточки in-place.
488
+ promise.then(items => {
489
+ if (state.filmHandle) return; // welcome больше не виден
490
+ if (JSON.stringify(items) === JSON.stringify(cloudItems)) return; // ничего не изменилось
491
+ renderWelcomeRecents().catch(() => {});
492
+ }).catch(() => {});
493
+ }
379
494
 
495
+ // ---- Объединённый список: cloud + folder, отсортированный по timestamp ----
496
+ // Каждый элемент: { type: 'cloud'|'recent', sortTs, item }.
497
+ // Для cloud берём максимум из (updatedAt | lastOpenedAt | createdAt) —
498
+ // юзер ожидает что недавно открытый проект (даже без сохранения) поднимется.
499
+ const merged = [];
500
+ for (const c of cloudItems) {
501
+ const ts = Math.max(c.lastOpenedAt || 0, c.updatedAt || 0, c.createdAt || 0);
502
+ merged.push({ type: 'cloud', sortTs: ts, item: c });
503
+ }
504
+ // Дедуп: cloud-проекты не должны показываться ещё и как «недавние папки».
505
+ // Источники дубля:
506
+ // 1) handle — cloud-shim (window.cloudFsShim.isCloudHandle) — фильтруем в touchRecent,
507
+ // но старые записи могли остаться от предыдущих версий.
508
+ // 2) имя совпадает с именем cloud-проекта — heuristic для legacy-записей.
509
+ const cloudNames = new Set(cloudItems.map(c => c.name).filter(Boolean));
380
510
  for (const r of list) {
381
- const card = document.createElement('div');
382
- card.className = 'welcome-card';
383
- const thumb = document.createElement('div');
384
- thumb.className = 'welcome-card-thumb';
385
- if (r.thumb) {
386
- const img = document.createElement('img');
387
- img.src = URL.createObjectURL(r.thumb);
388
- img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
389
- thumb.appendChild(img);
390
- } else {
391
- thumb.textContent = '🎬';
392
- }
393
- const meta = document.createElement('div');
394
- meta.className = 'welcome-card-meta';
395
- const nameEl = document.createElement('div');
396
- nameEl.className = 'welcome-card-name';
397
- nameEl.textContent = r.name;
398
- const tsEl = document.createElement('div');
399
- tsEl.className = 'welcome-card-ts';
400
- tsEl.textContent = fmtRelativeTime(r.ts);
401
- meta.append(nameEl, tsEl);
402
- card.append(thumb, meta);
403
- const del = document.createElement('div');
404
- del.className = 'welcome-card-del';
405
- del.textContent = '×';
406
- del.title = 'Удалить из недавних';
407
- del.addEventListener('click', async e => {
408
- e.stopPropagation();
409
- if (!confirm(`Убрать «${r.name}» из недавних? (папка не удаляется)`)) return;
410
- await removeRecent(r.name);
411
- await renderWelcomeRecents();
412
- });
413
- card.appendChild(del);
414
- card.addEventListener('click', async () => {
415
- try {
416
- if (r.handle) {
417
- let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
418
- if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
419
- vlog('info', `welcome card click ${r.name}: granted=${g}`);
420
- if (g) await openFilm(r.handle);
421
- else alert('Доступ к папке не подтверждён.');
422
- } else {
423
- // Handle потерялся (IDB сбросилась) — открываем picker прямо здесь,
424
- // чтобы сохранить user-gesture (через $('pickRoot').click() он
425
- // теряется и showDirectoryPicker валится SecurityError).
426
- try {
427
- const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'video-editor-film' });
428
- await openFilm(handle);
429
- } catch (err) {
430
- if (err.name === 'AbortError') return;
431
- throw err;
432
- }
511
+ if (window.cloudFsShim?.isCloudHandle?.(r.handle)) continue; // shim в IDB
512
+ if (cloudNames.has(r.name)) continue; // имя дубля
513
+ merged.push({ type: 'recent', sortTs: r.ts || 0, item: r });
514
+ }
515
+ merged.sort((a, b) => b.sortTs - a.sortTs);
516
+
517
+ // Title меняем в зависимости от наличия проектов.
518
+ if (titleEl) titleEl.textContent = merged.length ? 'Открыть проект · недавние' : 'Открыть проект';
519
+
520
+ for (const m of merged) {
521
+ if (m.type === 'cloud') grid.appendChild(makeCloudWelcomeCard(m.item));
522
+ else grid.appendChild(makeRecentWelcomeCard(m.item));
523
+ }
524
+ }
525
+
526
+ // Карточка локального (папочного) проекта — извлечена из renderWelcomeRecents.
527
+ function makeRecentWelcomeCard(r) {
528
+ const card = document.createElement('div');
529
+ card.className = 'welcome-card';
530
+ const thumb = document.createElement('div');
531
+ thumb.className = 'welcome-card-thumb';
532
+ if (r.thumb) {
533
+ const img = document.createElement('img');
534
+ img.src = URL.createObjectURL(r.thumb);
535
+ img.onload = () => setTimeout(() => URL.revokeObjectURL(img.src), 60_000);
536
+ thumb.appendChild(img);
537
+ } else thumb.textContent = '🎬';
538
+ const meta = document.createElement('div');
539
+ meta.className = 'welcome-card-meta';
540
+ const nameEl = document.createElement('div');
541
+ nameEl.className = 'welcome-card-name';
542
+ nameEl.textContent = r.name;
543
+ const tsEl = document.createElement('div');
544
+ tsEl.className = 'welcome-card-ts';
545
+ tsEl.textContent = fmtRelativeTime(r.ts);
546
+ meta.append(nameEl, tsEl);
547
+ card.append(thumb, meta);
548
+ const del = document.createElement('div');
549
+ del.className = 'welcome-card-del';
550
+ del.textContent = '×';
551
+ del.title = 'Удалить из недавних';
552
+ del.addEventListener('click', async e => {
553
+ e.stopPropagation();
554
+ if (!confirm(`Убрать «${r.name}» из недавних? (папка не удаляется)`)) return;
555
+ await removeRecent(r.name);
556
+ await renderWelcomeRecents();
557
+ });
558
+ card.appendChild(del);
559
+ card.addEventListener('click', async () => {
560
+ try {
561
+ if (r.handle) {
562
+ let g = (await r.handle.queryPermission({ mode: 'readwrite' })) === 'granted';
563
+ if (!g) g = (await r.handle.requestPermission({ mode: 'readwrite' })) === 'granted';
564
+ vlog('info', `welcome card click ${r.name}: granted=${g}`);
565
+ if (g) await openFilm(r.handle);
566
+ else alert('Доступ к папке не подтверждён.');
567
+ } else {
568
+ try {
569
+ const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'video-editor-film' });
570
+ await openFilm(handle);
571
+ } catch (err) {
572
+ if (err.name === 'AbortError') return;
573
+ throw err;
433
574
  }
434
- } catch (err) {
435
- vlog('err', 'welcome card failed: ' + (err?.message || err));
436
- alert('Ошибка: ' + (err?.message || err));
437
575
  }
438
- });
439
- grid.appendChild(card);
440
- }
576
+ } catch (err) {
577
+ vlog('err', 'welcome card failed: ' + (err?.message || err));
578
+ alert('Ошибка: ' + (err?.message || err));
579
+ }
580
+ });
581
+ return card;
582
+ }
583
+
584
+ // Карточка облачного проекта. Визуально такая же как у папочного, плюс
585
+ // ☁ бейдж в углу обложки. Клик → cloudProjects.open() (использует local
586
+ // cache если синхронизирован, иначе скачивает manifest+файлы).
587
+ // ПКМ → меню «Открыть в Finder», «Обновить из облака», «Удалить с сервера».
588
+ function makeCloudWelcomeCard(p) {
589
+ const card = document.createElement('div');
590
+ card.className = 'welcome-card cloud-card';
591
+ card.dataset.cloudId = p.id;
592
+
593
+ const thumb = document.createElement('div');
594
+ thumb.className = 'welcome-card-thumb';
595
+ if (p.coverUrl) {
596
+ const img = document.createElement('img');
597
+ img.src = '/api/proxy?url=' + encodeURIComponent(p.coverUrl);
598
+ img.draggable = false;
599
+ img.onerror = () => { thumb.textContent = '🎬'; img.remove(); };
600
+ thumb.appendChild(img);
601
+ } else thumb.textContent = '🎬';
602
+ // ☁-бейдж в нижнем правом углу обложки.
603
+ const cloudBadge = document.createElement('div');
604
+ cloudBadge.className = 'welcome-card-cloud-badge';
605
+ cloudBadge.textContent = '☁';
606
+ cloudBadge.title = 'Облачный проект';
607
+ thumb.appendChild(cloudBadge);
608
+
609
+ const meta = document.createElement('div');
610
+ meta.className = 'welcome-card-meta';
611
+ const nameEl = document.createElement('div');
612
+ nameEl.className = 'welcome-card-name';
613
+ nameEl.textContent = p.name || '(без названия)';
614
+ const tsEl = document.createElement('div');
615
+ tsEl.className = 'welcome-card-ts';
616
+ tsEl.textContent = p.updatedAt ? fmtRelativeTime(p.updatedAt) : 'облако';
617
+ meta.append(nameEl, tsEl);
618
+ card.append(thumb, meta);
619
+
620
+ card.addEventListener('click', () => {
621
+ if (window.cloudProjects?.open) window.cloudProjects.open(p.id, p.name);
622
+ });
623
+ card.addEventListener('contextmenu', e => {
624
+ e.preventDefault();
625
+ e.stopPropagation();
626
+ showCloudCardContextMenu(p, e.clientX, e.clientY);
627
+ });
628
+ return card;
629
+ }
630
+
631
+ // ПКМ-меню для облачной welcome-карточки.
632
+ function showCloudCardContextMenu(p, clientX, clientY) {
633
+ const menu = $('nodeMenu');
634
+ if (!menu) return;
635
+ menu.innerHTML = '';
636
+ const add = (label, fn, opts = {}) => {
637
+ const b = document.createElement('button');
638
+ b.textContent = label;
639
+ if (opts.danger) b.style.color = '#f88';
640
+ b.addEventListener('click', () => { menu.classList.add('hidden'); fn(); });
641
+ menu.appendChild(b);
642
+ };
643
+ add('📂 Открыть в Finder', async () => {
644
+ if (!window.cloudFs?.openInFinder) {
645
+ alert('Доступно только в Electron-приложении');
646
+ return;
647
+ }
648
+ const r = await window.cloudFs.openInFinder(p.id);
649
+ if (!r?.ok) alert('Не удалось открыть: ' + (r?.error || 'unknown'));
650
+ });
651
+ add('↻ Обновить из облака', () => {
652
+ if (window.cloudProjects?.open) window.cloudProjects.open(p.id, p.name, { forceRefresh: true });
653
+ });
654
+ add('🗑 Удалить с сервера', async () => {
655
+ if (!confirm(`Удалить «${p.name}» с сервера? Локальная копия в userData останется.`)) return;
656
+ try {
657
+ const r = await fetch('/api/projects/' + encodeURIComponent(p.id), { method: 'DELETE' });
658
+ if (!r.ok) throw new Error('HTTP ' + r.status);
659
+ // Очищаем кэш и перерендериваем grid.
660
+ try {
661
+ const cached = JSON.parse(localStorage.getItem('cloudProjectsCache') || '[]');
662
+ localStorage.setItem('cloudProjectsCache', JSON.stringify(cached.filter(c => c.id !== p.id)));
663
+ } catch {}
664
+ await renderWelcomeRecents();
665
+ } catch (e) { alert('Не удалось удалить: ' + (e?.message || e)); }
666
+ }, { danger: true });
667
+ positionFloatingMenu(menu, clientX, clientY);
668
+ setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
441
669
  }
442
670
 
443
671
  // Скопировать файл image-ноды в корень проекта как `.cover.<ext>`.
@@ -619,6 +847,8 @@ async function openFilm(handle) {
619
847
  loadUploadCache().catch(() => {});
620
848
  await refreshSidebar();
621
849
  showEmpty();
850
+ // Cloud-кнопки: показать «☁ Сохранить на сервер» когда проект открыт.
851
+ if (window.cloudProjects?.setVisibility) window.cloudProjects.setVisibility();
622
852
 
623
853
  const raw = localStorage.getItem(`lastBoard:${handle.name}`);
624
854
  if (raw) {
@@ -659,7 +889,13 @@ async function openFilm(handle) {
659
889
  // Закрыть текущий проект: выгрузить state, скрыть секции, восстановить
660
890
  // шапку, оставить запись в idb (чтобы Recent работал).
661
891
  async function closeProject() {
892
+ // Помечаем что юзер вышел явно — на следующем старте autoload пропускается.
893
+ // Cmd+R после close = welcome, а не реоткрытие.
894
+ try { localStorage.setItem('welcomeOnNextStart', '1'); } catch {}
662
895
  stopExternalWatcher();
896
+ // Сбрасываем UI таймлайна/превью — иначе при возврате через welcome
897
+ // в новый проект остаётся фрейм/дорожки прошлого.
898
+ if (typeof resetTimelineUI === 'function') resetTimelineUI();
663
899
  if (state.currentBoard?.urls) {
664
900
  for (const u of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(u);
665
901
  }
@@ -668,6 +904,9 @@ async function closeProject() {
668
904
  if (typeof revokeMentionThumbCache === 'function') revokeMentionThumbCache();
669
905
  state.filmHandle = null;
670
906
  state.currentBoard = null;
907
+ // Сбрасываем cloud-маркеры (если был открыт облачный проект).
908
+ state.cloudProjectId = null;
909
+ state.cloudDirty = false;
671
910
  window.appProject?.notifyState(false);
672
911
  state.charactersInfo = [];
673
912
  state.locationsInfo = [];
@@ -675,6 +914,8 @@ async function closeProject() {
675
914
  state.selectedClipIds.clear();
676
915
  state.selectedTrackIds.clear();
677
916
  document.body.classList.add('no-project');
917
+ // Видимость cloud-кнопок зависит от наличия открытого проекта — переключаем.
918
+ if (window.cloudProjects?.setVisibility) window.cloudProjects.setVisibility();
678
919
  const sub = $('brandSub');
679
920
  if (sub) { sub.textContent = 'Видео-редактор'; sub.classList.remove('has-project'); }
680
921
  const boardEl = $('brandBoard');
@@ -757,21 +998,9 @@ function showBoardContextMenu(kind, item, clientX, clientY) {
757
998
  selectBoard({ kind, ...item }).then(() => openCharacterSettings()).catch(() => {});
758
999
  });
759
1000
  }
760
- // Сохранение этого board'а как шаблон. saveCurrentBoardAsTemplate
761
- // работает с state.currentBoard, поэтому сначала открываем нужный
762
- // board (если он не текущий), а затем запускаем сохранение.
763
- add('💾 Сохранить как шаблон', async () => {
764
- try {
765
- if (!state.currentBoard || state.currentBoard.kind !== kind || state.currentBoard.name !== item.name) {
766
- await selectBoard({ kind, ...item });
767
- }
768
- if (typeof saveCurrentBoardAsTemplate === 'function') {
769
- await saveCurrentBoardAsTemplate();
770
- }
771
- } catch (e) {
772
- alert('Не удалось сохранить как шаблон: ' + (e?.message || e));
773
- }
774
- });
1001
+ // «💾 Сохранить как шаблон» скрыт: scene-templates по упрощённой
1002
+ // модели больше не используются (только project-templates). Код
1003
+ // saveCurrentBoardAsTemplate сохранён может пригодиться позже.
775
1004
  add('🗑 Удалить', async () => {
776
1005
  if (!confirm(`Удалить «${item.name}»? Папка переедет в _deleted, Cmd+Z восстановит.`)) return;
777
1006
  try { await deleteBoard(kind, item.name); }
@@ -801,6 +1030,8 @@ async function promptBoardAspectRatio(kind, item) {
801
1030
  if (!state.currentBoard.metadata.settings) state.currentBoard.metadata.settings = {};
802
1031
  state.currentBoard.metadata.settings.aspectRatio = chosen;
803
1032
  scheduleSave();
1033
+ // Обновить aspect-label в заголовке таймлайна (если открыт).
1034
+ if (typeof updateTimelineAspectLabel === 'function') updateTimelineAspectLabel();
804
1035
  } else {
805
1036
  // Доска не активна — пишем напрямую.
806
1037
  const meta = await loadBoardMetadata(item.handle);
@@ -1329,6 +1560,10 @@ async function selectBoard(board) {
1329
1560
  if (state.currentBoard?.urls) {
1330
1561
  for (const url of Object.values(state.currentBoard.urls)) URL.revokeObjectURL(url);
1331
1562
  }
1563
+ // Сбрасываем UI таймлайна/превью (иначе остаётся фрейм/контент прошлой сцены
1564
+ // пока юзер не нажмёт ▶ — особенно заметно при переключении на сцену
1565
+ // без таймлайна вовсе).
1566
+ if (typeof resetTimelineUI === 'function') resetTimelineUI();
1332
1567
  clearSelection();
1333
1568
  state.selectedClipIds.clear();
1334
1569
  state.selectedTrackIds.clear();
@@ -2007,6 +2242,29 @@ function showNodeContextMenu(node, clientX, clientY) {
2007
2242
  setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
2008
2243
  }
2009
2244
 
2245
+ // ПКМ на brand-area sidebar — действия с проектом (сохранить как шаблон).
2246
+ // Показывает «Создать новый шаблон» всегда, и «Обновить «X»» если проект
2247
+ // был открыт из шаблона юзера (см. saveCurrentProjectAsTemplate — он сам
2248
+ // определит mode по .kingkont-meta.json).
2249
+ function showProjectContextMenu(clientX, clientY) {
2250
+ const menu = $('nodeMenu');
2251
+ menu.innerHTML = '';
2252
+ const add = (label, fn) => {
2253
+ const b = document.createElement('button');
2254
+ b.textContent = label;
2255
+ b.addEventListener('click', () => {
2256
+ menu.classList.add('hidden');
2257
+ fn();
2258
+ });
2259
+ menu.appendChild(b);
2260
+ };
2261
+ add('💾 Сохранить как шаблон…', () => {
2262
+ if (typeof saveCurrentProjectAsTemplate === 'function') saveCurrentProjectAsTemplate();
2263
+ });
2264
+ positionFloatingMenu(menu, clientX, clientY);
2265
+ setTimeout(() => document.addEventListener('mousedown', closeNodeMenu, { once: true }), 0);
2266
+ }
2267
+
2010
2268
  // ПКМ на 📚 Templates-кнопке — действия проектного уровня.
2011
2269
  function showTemplatesButtonContextMenu(clientX, clientY) {
2012
2270
  const menu = $('nodeMenu');