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/assets/templates.jpg +0 -0
- package/index.html +42 -3
- package/lib/providers.js +131 -2
- package/main.js +120 -0
- package/package.json +1 -1
- package/preload.js +38 -12
- package/renderer/board.js +345 -87
- package/renderer/cloudFs.js +271 -0
- package/renderer/cloudProjects.js +612 -0
- package/renderer/generate.js +7 -0
- package/renderer/settings.js +40 -1
- package/renderer/state.js +5 -0
- package/renderer/styles.css +97 -17
- package/renderer/templates.js +286 -62
- package/renderer/timeline.js +149 -7
- package/server.js +63 -1
- package/skill/SKILL.md +109 -0
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
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');
|