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/templates.js
CHANGED
|
@@ -21,21 +21,60 @@
|
|
|
21
21
|
// 5. Записать scene.json
|
|
22
22
|
// 6. selectBoard(новый board)
|
|
23
23
|
|
|
24
|
+
// Прогресс upload/download шаблонов. Дублирует значение в двух местах:
|
|
25
|
+
// (а) штатный прогресс-бар внутри templates-модалки — виден когда юзер
|
|
26
|
+
// активно работает с библиотекой; (б) фиксированный toast в верху
|
|
27
|
+
// экрана — виден всегда, в т.ч. когда юзер открыл шаблон проекта
|
|
28
|
+
// с welcome-screen и templates-модалка не открыта.
|
|
24
29
|
const TPL_PROGRESS = {
|
|
25
30
|
el: () => document.getElementById('tplProgress'),
|
|
26
31
|
label: () => document.getElementById('tplProgressLabel'),
|
|
27
32
|
bar: () => document.getElementById('tplProgressBar'),
|
|
33
|
+
toast: () => {
|
|
34
|
+
let t = document.getElementById('tplToast');
|
|
35
|
+
if (t) return t;
|
|
36
|
+
// Лениво создаём toast — div поверх всего, fixed top-center.
|
|
37
|
+
t = document.createElement('div');
|
|
38
|
+
t.id = 'tplToast';
|
|
39
|
+
t.style.cssText =
|
|
40
|
+
'position:fixed; top:16px; left:50%; transform:translateX(-50%);' +
|
|
41
|
+
'background:#1a1a1a; border:1px solid #3a3a3a; border-radius:8px;' +
|
|
42
|
+
'padding:10px 14px; box-shadow:0 8px 28px rgba(0,0,0,0.6);' +
|
|
43
|
+
'z-index:99999; min-width:340px; max-width:600px; display:none;';
|
|
44
|
+
t.innerHTML =
|
|
45
|
+
'<div id="tplToastLabel" style="font-size:12px; color:#ddd; margin-bottom:6px;"></div>' +
|
|
46
|
+
'<div style="width:100%; height:6px; background:#333; border-radius:3px; overflow:hidden;">' +
|
|
47
|
+
'<div id="tplToastBar" style="width:0; height:100%; background:#3a5a8a; transition:width 0.2s;"></div>' +
|
|
48
|
+
'</div>';
|
|
49
|
+
document.body.appendChild(t);
|
|
50
|
+
return t;
|
|
51
|
+
},
|
|
28
52
|
show(label) {
|
|
29
|
-
TPL_PROGRESS.el()
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
if (TPL_PROGRESS.el()) {
|
|
54
|
+
TPL_PROGRESS.el().classList.remove('hidden');
|
|
55
|
+
TPL_PROGRESS.label().textContent = label;
|
|
56
|
+
TPL_PROGRESS.bar().style.width = '0%';
|
|
57
|
+
}
|
|
58
|
+
const t = TPL_PROGRESS.toast();
|
|
59
|
+
t.style.display = 'block';
|
|
60
|
+
document.getElementById('tplToastLabel').textContent = label;
|
|
61
|
+
document.getElementById('tplToastBar').style.width = '0%';
|
|
32
62
|
},
|
|
33
63
|
update(done, total, label) {
|
|
34
|
-
if (label) TPL_PROGRESS.label().textContent = label;
|
|
35
64
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
36
|
-
TPL_PROGRESS.
|
|
65
|
+
if (TPL_PROGRESS.el()) {
|
|
66
|
+
if (label) TPL_PROGRESS.label().textContent = label;
|
|
67
|
+
TPL_PROGRESS.bar().style.width = pct + '%';
|
|
68
|
+
}
|
|
69
|
+
const t = TPL_PROGRESS.toast();
|
|
70
|
+
if (label) document.getElementById('tplToastLabel').textContent = label;
|
|
71
|
+
document.getElementById('tplToastBar').style.width = pct + '%';
|
|
72
|
+
},
|
|
73
|
+
hide() {
|
|
74
|
+
if (TPL_PROGRESS.el()) TPL_PROGRESS.el().classList.add('hidden');
|
|
75
|
+
const t = document.getElementById('tplToast');
|
|
76
|
+
if (t) t.style.display = 'none';
|
|
37
77
|
},
|
|
38
|
-
hide() { TPL_PROGRESS.el().classList.add('hidden'); },
|
|
39
78
|
};
|
|
40
79
|
|
|
41
80
|
function tplStatus(msg, isError) {
|
|
@@ -77,7 +116,15 @@ async function refreshTemplatesList() {
|
|
|
77
116
|
throw new Error(err.error || `HTTP ${r.status}`);
|
|
78
117
|
}
|
|
79
118
|
const data = await r.json();
|
|
80
|
-
|
|
119
|
+
let items = Array.isArray(data) ? data : (data.items || data.templates || []);
|
|
120
|
+
// Скрываем scene/character/location-шаблоны — оставляем ТОЛЬКО проекты.
|
|
121
|
+
// (Раньше при открытом проекте можно было добавить scene-шаблон как
|
|
122
|
+
// новую сцену. Сейчас концепция упрощается до cloud-projects, но код
|
|
123
|
+
// scene-templates оставлен — может пригодиться позже.)
|
|
124
|
+
items = items.filter(t => t.kind === 'project');
|
|
125
|
+
// Когда проект открыт — open-project-template создаст НОВЫЙ проект,
|
|
126
|
+
// что в этот момент бессмысленно. Скрываем всё.
|
|
127
|
+
if (state.filmHandle) items = [];
|
|
81
128
|
renderTemplatesList(items);
|
|
82
129
|
} catch (e) {
|
|
83
130
|
list.innerHTML = '';
|
|
@@ -135,18 +182,27 @@ function renderTemplatesList(items) {
|
|
|
135
182
|
openBtn.title = 'Выбрать папку и развернуть в неё проект';
|
|
136
183
|
openBtn.addEventListener('click', () => openProjectTemplate(t.id, t.name));
|
|
137
184
|
} else {
|
|
138
|
-
|
|
139
|
-
|
|
185
|
+
// Scene-templates: при открытом проекте — добавляются board'ом внутрь;
|
|
186
|
+
// без проекта (welcome-screen) — создаётся новый проект с одной этой
|
|
187
|
+
// сценой (см. openTemplate: ветка createdNewProject).
|
|
188
|
+
openBtn.disabled = false;
|
|
189
|
+
openBtn.title = state.filmHandle
|
|
190
|
+
? 'Скачать локально и открыть как новую сцену'
|
|
191
|
+
: 'Создать новый проект с этой сценой';
|
|
140
192
|
openBtn.addEventListener('click', () => openTemplate(t.id, t.name));
|
|
141
193
|
}
|
|
142
194
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
195
|
+
card.append(info, openBtn);
|
|
196
|
+
// Корзина — для своих шаблонов и для админов (canModify приходит с
|
|
197
|
+
// сервера, см. canModifyTemplate в spaces/server/api/templates.ts).
|
|
198
|
+
if (t.canModify || t.mine) {
|
|
199
|
+
const delBtn = document.createElement('button');
|
|
200
|
+
delBtn.className = 'danger';
|
|
201
|
+
delBtn.textContent = '🗑';
|
|
202
|
+
delBtn.title = t.mine ? 'Удалить свой шаблон' : 'Удалить шаблон (admin)';
|
|
203
|
+
delBtn.addEventListener('click', () => deleteTemplateConfirm(t.id, t.name));
|
|
204
|
+
card.appendChild(delBtn);
|
|
205
|
+
}
|
|
150
206
|
list.appendChild(card);
|
|
151
207
|
}
|
|
152
208
|
}
|
|
@@ -272,9 +328,42 @@ async function saveCurrentProjectAsTemplate() {
|
|
|
272
328
|
alert('Сначала открой проект');
|
|
273
329
|
return;
|
|
274
330
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
331
|
+
|
|
332
|
+
// Меta — может содержать templateId (если проект из шаблона) и fileHashes.
|
|
333
|
+
const meta = (await readProjectMeta(state.filmHandle)) || {};
|
|
334
|
+
|
|
335
|
+
// Если проект из шаблона + юзер автор (или админ) → предлагаем выбор.
|
|
336
|
+
// canModify приходит с сервера: ownerId === userId OR ctx.account.is('Admin').
|
|
337
|
+
let updateExisting = null; // template object или null
|
|
338
|
+
if (meta.templateId) {
|
|
339
|
+
try {
|
|
340
|
+
const r = await fetch(`/api/templates/${encodeURIComponent(meta.templateId)}`);
|
|
341
|
+
if (r.ok) {
|
|
342
|
+
const tpl = await r.json();
|
|
343
|
+
if (tpl.canModify) updateExisting = tpl;
|
|
344
|
+
}
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let mode = 'create'; // 'create' | 'update'
|
|
349
|
+
let name;
|
|
350
|
+
if (updateExisting) {
|
|
351
|
+
const choice = await askChoice(
|
|
352
|
+
'Сохранить шаблон проекта',
|
|
353
|
+
[`Обновить «${updateExisting.name}»`, 'Создать новый шаблон'],
|
|
354
|
+
`Обновить «${updateExisting.name}»`,
|
|
355
|
+
);
|
|
356
|
+
if (!choice) return;
|
|
357
|
+
if (choice.startsWith('Обновить')) {
|
|
358
|
+
mode = 'update';
|
|
359
|
+
name = updateExisting.name;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (mode === 'create') {
|
|
363
|
+
const defaultName = state.filmHandle.name || 'Проект';
|
|
364
|
+
name = await askName('Имя шаблона проекта:', defaultName, defaultName, { okText: 'Сохранить' });
|
|
365
|
+
if (!name) return;
|
|
366
|
+
}
|
|
278
367
|
|
|
279
368
|
const modal = document.getElementById('templatesModal');
|
|
280
369
|
if (modal) modal.classList.remove('hidden');
|
|
@@ -284,33 +373,43 @@ async function saveCurrentProjectAsTemplate() {
|
|
|
284
373
|
tplStatus('');
|
|
285
374
|
|
|
286
375
|
try {
|
|
287
|
-
// 1. Собираем все board'
|
|
376
|
+
// 1. Собираем все board'ы.
|
|
288
377
|
const allBoards = await collectProjectBoards(state.filmHandle);
|
|
289
378
|
if (!allBoards.length) {
|
|
290
379
|
throw new Error('В проекте нет ни одной сцены/персонажа/локации');
|
|
291
380
|
}
|
|
292
381
|
|
|
293
|
-
// 2. Считаем общее число файлов для прогресса.
|
|
294
382
|
const totalFiles = allBoards.reduce((sum, b) => sum + b.mediaFiles.length, 0);
|
|
295
383
|
let uploadedTotal = 0;
|
|
384
|
+
let reusedTotal = 0; // dedup'нутые из meta.fileHashes
|
|
385
|
+
const oldHashes = meta.fileHashes || {};
|
|
386
|
+
const newHashes = {}; // обновлённый кэш для записи обратно в meta
|
|
296
387
|
|
|
297
|
-
//
|
|
298
|
-
// структуру { kind, name, manifest, files: {relPath: cdnUrl} }.
|
|
388
|
+
// 2. Для каждого board'а — собираем boardsPayload с files map.
|
|
299
389
|
const boardsPayload = [];
|
|
300
390
|
for (const b of allBoards) {
|
|
301
391
|
let manifest = {};
|
|
302
392
|
try {
|
|
303
393
|
const sceneFh = await b.handle.getFileHandle('scene.json');
|
|
304
394
|
manifest = JSON.parse(await (await sceneFh.getFile()).text());
|
|
305
|
-
} catch {
|
|
306
|
-
// Без scene.json пропускаем — это не board.
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
395
|
+
} catch { continue; }
|
|
309
396
|
const filesMap = {};
|
|
310
397
|
for (const f of b.mediaFiles) {
|
|
398
|
+
const blob = await f.fileHandle.getFile();
|
|
399
|
+
const metaKey = `${b.kind}/${b.name}/${f.relPath}`;
|
|
400
|
+
const cached = oldHashes[metaKey];
|
|
401
|
+
// Dedup: если файл не менялся (size + mtime совпадают) — переиспользуем CDN-URL.
|
|
402
|
+
if (cached && cached.size === blob.size && cached.mtime === blob.lastModified) {
|
|
403
|
+
filesMap[f.relPath] = cached.url;
|
|
404
|
+
newHashes[metaKey] = cached;
|
|
405
|
+
reusedTotal++;
|
|
406
|
+
uploadedTotal++;
|
|
407
|
+
TPL_PROGRESS.update(uploadedTotal, totalFiles,
|
|
408
|
+
`[${b.kind}/${b.name}] ${f.relPath} (cached, ${uploadedTotal}/${totalFiles})`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
311
411
|
TPL_PROGRESS.update(uploadedTotal, totalFiles,
|
|
312
412
|
`[${b.kind}/${b.name}] ${f.relPath} (${uploadedTotal + 1}/${totalFiles})`);
|
|
313
|
-
const blob = await f.fileHandle.getFile();
|
|
314
413
|
const r = await fetch('/api/upload', {
|
|
315
414
|
method: 'POST',
|
|
316
415
|
headers: {
|
|
@@ -325,42 +424,68 @@ async function saveCurrentProjectAsTemplate() {
|
|
|
325
424
|
}
|
|
326
425
|
const { url } = await r.json();
|
|
327
426
|
filesMap[f.relPath] = url;
|
|
427
|
+
newHashes[metaKey] = { url, size: blob.size, mtime: blob.lastModified };
|
|
328
428
|
uploadedTotal++;
|
|
329
429
|
}
|
|
330
|
-
boardsPayload.push({
|
|
331
|
-
kind: b.kind,
|
|
332
|
-
name: b.name,
|
|
333
|
-
manifest,
|
|
334
|
-
files: filesMap,
|
|
335
|
-
});
|
|
430
|
+
boardsPayload.push({ kind: b.kind, name: b.name, manifest, files: filesMap });
|
|
336
431
|
}
|
|
337
432
|
|
|
338
433
|
TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Загрузка обложки…');
|
|
339
434
|
const coverUrl = await uploadProjectCoverIfAny();
|
|
340
|
-
TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Регистрация шаблона проекта…');
|
|
341
435
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
name,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
436
|
+
const body = {
|
|
437
|
+
name,
|
|
438
|
+
kind: 'project',
|
|
439
|
+
manifest: {
|
|
440
|
+
boards: boardsPayload,
|
|
441
|
+
projectName: state.filmHandle.name,
|
|
442
|
+
...(coverUrl ? { coverUrl } : {}),
|
|
443
|
+
},
|
|
444
|
+
files: {},
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
let resultId;
|
|
448
|
+
if (mode === 'update') {
|
|
449
|
+
TPL_PROGRESS.update(uploadedTotal, totalFiles, `Обновление «${updateExisting.name}»…`);
|
|
450
|
+
const r = await fetch(`/api/templates/${encodeURIComponent(updateExisting.id)}`, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'Content-Type': 'application/json' },
|
|
453
|
+
body: JSON.stringify(body),
|
|
454
|
+
});
|
|
455
|
+
if (!r.ok) {
|
|
456
|
+
const err = await r.json().catch(() => ({}));
|
|
457
|
+
throw new Error(err.error || `HTTP ${r.status}`);
|
|
458
|
+
}
|
|
459
|
+
resultId = updateExisting.id;
|
|
460
|
+
} else {
|
|
461
|
+
TPL_PROGRESS.update(uploadedTotal, totalFiles, 'Регистрация шаблона проекта…');
|
|
462
|
+
const r = await fetch('/api/templates', {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'Content-Type': 'application/json' },
|
|
465
|
+
body: JSON.stringify(body),
|
|
466
|
+
});
|
|
467
|
+
if (!r.ok) {
|
|
468
|
+
const err = await r.json().catch(() => ({}));
|
|
469
|
+
throw new Error(err.error || `HTTP ${r.status}`);
|
|
470
|
+
}
|
|
471
|
+
const created = await r.json();
|
|
472
|
+
resultId = created.id;
|
|
360
473
|
}
|
|
361
474
|
|
|
475
|
+
// Обновляем meta-файл — сохраняем новый templateId (или подтверждаем
|
|
476
|
+
// существующий) + актуальный fileHashes для следующего save.
|
|
477
|
+
await writeProjectMeta(state.filmHandle, {
|
|
478
|
+
version: 1,
|
|
479
|
+
templateId: resultId,
|
|
480
|
+
templateName: name,
|
|
481
|
+
savedAt: Date.now(),
|
|
482
|
+
fileHashes: newHashes,
|
|
483
|
+
});
|
|
484
|
+
|
|
362
485
|
TPL_PROGRESS.hide();
|
|
363
|
-
|
|
486
|
+
const reusedNote = reusedTotal ? `, ${reusedTotal} переиспользовано из кэша` : '';
|
|
487
|
+
const action = mode === 'update' ? 'обновлён' : 'сохранён';
|
|
488
|
+
tplStatus(`Шаблон проекта «${name}» ${action} (${allBoards.length} board'ов, ${totalFiles} файл${totalFiles === 1 ? '' : 'ов'}${reusedNote})`);
|
|
364
489
|
await refreshTemplatesList();
|
|
365
490
|
} catch (e) {
|
|
366
491
|
TPL_PROGRESS.hide();
|
|
@@ -411,20 +536,58 @@ async function collectProjectBoards(filmHandle) {
|
|
|
411
536
|
return validBoards;
|
|
412
537
|
}
|
|
413
538
|
|
|
539
|
+
// Guard от повторного входа: showDirectoryPicker валится с
|
|
540
|
+
// «File picker already active» если юзер быстро кликнул карточку
|
|
541
|
+
// дважды или если предыдущий picker ещё не закрылся.
|
|
542
|
+
let _openProjectTplInFlight = false;
|
|
543
|
+
|
|
544
|
+
// =================== Meta-файл проекта ===================
|
|
545
|
+
// `<project>/.kingkont-meta.json` хранит:
|
|
546
|
+
// { templateId, templateName, fileHashes: { relPath: { url, size, mtime } } }
|
|
547
|
+
// Используется для:
|
|
548
|
+
// • проверки «можно ли обновить шаблон» (templateId известен)
|
|
549
|
+
// • dedup-а при повторной заливке (если файл не менялся — переиспользуем
|
|
550
|
+
// CDN-URL вместо загрузки)
|
|
551
|
+
const META_FILE = '.kingkont-meta.json';
|
|
552
|
+
|
|
553
|
+
async function readProjectMeta(filmHandle) {
|
|
554
|
+
if (!filmHandle) return null;
|
|
555
|
+
try {
|
|
556
|
+
const fh = await filmHandle.getFileHandle(META_FILE);
|
|
557
|
+
const txt = await (await fh.getFile()).text();
|
|
558
|
+
return JSON.parse(txt);
|
|
559
|
+
} catch { return null; }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function writeProjectMeta(filmHandle, meta) {
|
|
563
|
+
if (!filmHandle) return;
|
|
564
|
+
try {
|
|
565
|
+
const fh = await filmHandle.getFileHandle(META_FILE, { create: true });
|
|
566
|
+
const w = await fh.createWritable();
|
|
567
|
+
await w.write(JSON.stringify(meta, null, 2));
|
|
568
|
+
await w.close();
|
|
569
|
+
} catch (e) {
|
|
570
|
+
console.warn('writeProjectMeta failed:', e?.message || e);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
414
574
|
// =================== Open project template ===================
|
|
415
575
|
// Просим юзера выбрать parent-папку. Создаём в ней новую папку с именем
|
|
416
576
|
// проекта. Скачиваем все boards со всеми files. Открываем filmHandle.
|
|
417
577
|
async function openProjectTemplate(templateId, suggestedName) {
|
|
578
|
+
if (_openProjectTplInFlight) return;
|
|
418
579
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
419
580
|
alert('Браузер не поддерживает выбор папки. Открой проект через File-System-Access.');
|
|
420
581
|
return;
|
|
421
582
|
}
|
|
422
583
|
|
|
584
|
+
_openProjectTplInFlight = true;
|
|
423
585
|
// 1. Парент-папка от юзера.
|
|
424
586
|
let parentHandle;
|
|
425
587
|
try {
|
|
426
588
|
parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
427
589
|
} catch (e) {
|
|
590
|
+
_openProjectTplInFlight = false;
|
|
428
591
|
if (e.name === 'AbortError') return; // юзер закрыл диалог
|
|
429
592
|
alert('Не удалось выбрать папку: ' + e.message);
|
|
430
593
|
return;
|
|
@@ -437,7 +600,7 @@ async function openProjectTemplate(templateId, suggestedName) {
|
|
|
437
600
|
suggestedName || '',
|
|
438
601
|
{ okText: 'Создать и открыть' },
|
|
439
602
|
);
|
|
440
|
-
if (!projectName) return;
|
|
603
|
+
if (!projectName) { _openProjectTplInFlight = false; return; }
|
|
441
604
|
|
|
442
605
|
TPL_PROGRESS.show('Получение шаблона…');
|
|
443
606
|
tplStatus('');
|
|
@@ -461,6 +624,9 @@ async function openProjectTemplate(templateId, suggestedName) {
|
|
|
461
624
|
let downloadedTotal = 0;
|
|
462
625
|
|
|
463
626
|
// 5. Для каждого board'а — создаём папку, качаем файлы, пишем scene.json.
|
|
627
|
+
// По ходу собираем fileHashes для meta — `<kind>/<board>/<relPath>` → cdnUrl.
|
|
628
|
+
// (size/mtime читаем из созданного файла после write — нужно для dedup.)
|
|
629
|
+
const fileHashes = {};
|
|
464
630
|
for (const b of boards) {
|
|
465
631
|
let parent = filmHandle;
|
|
466
632
|
if (b.kind === 'character') {
|
|
@@ -479,12 +645,31 @@ async function openProjectTemplate(templateId, suggestedName) {
|
|
|
479
645
|
if (!fr.ok) throw new Error(`download ${b.name}/${relPath}: HTTP ${fr.status}`);
|
|
480
646
|
const buf = await fr.arrayBuffer();
|
|
481
647
|
await writeBoardFile(boardHandle, relPath, new Uint8Array(buf));
|
|
648
|
+
// Запомним size+mtime для dedup'а при следующем save.
|
|
649
|
+
try {
|
|
650
|
+
const parts = relPath.split('/');
|
|
651
|
+
let dh = boardHandle;
|
|
652
|
+
for (let i = 0; i < parts.length - 1; i++) dh = await dh.getDirectoryHandle(parts[i]);
|
|
653
|
+
const fh = await dh.getFileHandle(parts[parts.length - 1]);
|
|
654
|
+
const f = await fh.getFile();
|
|
655
|
+
const key = `${b.kind}/${b.name}/${relPath}`;
|
|
656
|
+
fileHashes[key] = { url: cdnUrl, size: f.size, mtime: f.lastModified };
|
|
657
|
+
} catch {}
|
|
482
658
|
downloadedTotal++;
|
|
483
659
|
}
|
|
484
660
|
// scene.json — manifest board'а.
|
|
485
661
|
await writeBoardFile(boardHandle, 'scene.json', JSON.stringify(b.manifest || {}, null, 2));
|
|
486
662
|
}
|
|
487
663
|
|
|
664
|
+
// Meta: запоминаем шаблон-источник + размеры/мтайм всех файлов для dedup'а.
|
|
665
|
+
await writeProjectMeta(filmHandle, {
|
|
666
|
+
version: 1,
|
|
667
|
+
templateId: tpl.id,
|
|
668
|
+
templateName: tpl.name || '',
|
|
669
|
+
downloadedAt: Date.now(),
|
|
670
|
+
fileHashes,
|
|
671
|
+
});
|
|
672
|
+
|
|
488
673
|
// 6. Открываем созданный проект.
|
|
489
674
|
TPL_PROGRESS.update(totalFiles, totalFiles, 'Открытие проекта…');
|
|
490
675
|
// Чистим stale lastBoard:<name> — иначе если у юзера раньше был
|
|
@@ -513,20 +698,59 @@ async function openProjectTemplate(templateId, suggestedName) {
|
|
|
513
698
|
TPL_PROGRESS.hide();
|
|
514
699
|
tplStatus('Ошибка открытия проекта: ' + e.message, true);
|
|
515
700
|
console.error('open project template failed', e);
|
|
701
|
+
} finally {
|
|
702
|
+
_openProjectTplInFlight = false;
|
|
516
703
|
}
|
|
517
704
|
}
|
|
518
705
|
|
|
519
706
|
// =================== Open template (download all files + create board) ===================
|
|
707
|
+
// Если есть открытый проект — board создаётся внутри него.
|
|
708
|
+
// Если проекта нет (welcome-screen) — просим выбрать parent-папку, создаём
|
|
709
|
+
// новый проект с одной сценой/персонажем/локацией из шаблона, открываем.
|
|
520
710
|
async function openTemplate(templateId, suggestedName) {
|
|
521
|
-
|
|
711
|
+
// Welcome-flow: создаём новый проект под одну сцену.
|
|
712
|
+
let createdNewProject = false;
|
|
713
|
+
if (!state.filmHandle) {
|
|
714
|
+
if (typeof window.showDirectoryPicker !== 'function') {
|
|
715
|
+
alert('Браузер не поддерживает выбор папки.'); return;
|
|
716
|
+
}
|
|
717
|
+
let parentHandle;
|
|
718
|
+
try {
|
|
719
|
+
parentHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
|
720
|
+
} catch (e) {
|
|
721
|
+
if (e.name === 'AbortError') return;
|
|
722
|
+
alert('Не удалось выбрать папку: ' + e.message);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const projectName = await askName(
|
|
726
|
+
'Имя нового проекта:',
|
|
727
|
+
suggestedName || 'Из шаблона',
|
|
728
|
+
suggestedName || '',
|
|
729
|
+
{ okText: 'Создать' },
|
|
730
|
+
);
|
|
731
|
+
if (!projectName) return;
|
|
732
|
+
try {
|
|
733
|
+
const filmHandle = await parentHandle.getDirectoryHandle(projectName, { create: true });
|
|
734
|
+
try { localStorage.removeItem(`lastBoard:${projectName}`); } catch {}
|
|
735
|
+
await openFilm(filmHandle);
|
|
736
|
+
createdNewProject = true;
|
|
737
|
+
} catch (e) {
|
|
738
|
+
alert('Не удалось создать папку проекта: ' + e.message);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
522
742
|
|
|
523
743
|
// 1. Спросить имя нового board'а в текущем проекте.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
suggestedName || ''
|
|
528
|
-
|
|
529
|
-
|
|
744
|
+
// Если только что создали проект — имя сцены берём из шаблона без вопроса
|
|
745
|
+
// (юзер уже задал имя проекта, лишний диалог раздражает).
|
|
746
|
+
const name = createdNewProject
|
|
747
|
+
? (suggestedName || 'Сцена 1')
|
|
748
|
+
: await askName(
|
|
749
|
+
'Имя для скачанной сцены:',
|
|
750
|
+
suggestedName || 'из шаблона',
|
|
751
|
+
suggestedName || '',
|
|
752
|
+
{ okText: 'Скачать и открыть' },
|
|
753
|
+
);
|
|
530
754
|
if (!name) return;
|
|
531
755
|
|
|
532
756
|
TPL_PROGRESS.show('Получение шаблона…');
|