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.
@@ -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().classList.remove('hidden');
30
- TPL_PROGRESS.label().textContent = label;
31
- TPL_PROGRESS.bar().style.width = '0%';
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.bar().style.width = pct + '%';
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
- const items = Array.isArray(data) ? data : (data.items || data.templates || []);
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
- openBtn.disabled = !state.filmHandle;
139
- openBtn.title = state.filmHandle ? 'Скачать локально и открыть как новую сцену' : 'Сначала открой проект';
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
- const delBtn = document.createElement('button');
144
- delBtn.className = 'danger';
145
- delBtn.textContent = '🗑';
146
- delBtn.title = 'Удалить шаблон';
147
- delBtn.addEventListener('click', () => deleteTemplateConfirm(t.id, t.name));
148
-
149
- card.append(info, openBtn, delBtn);
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
- const defaultName = state.filmHandle.name || 'Проект';
276
- const name = await askName('Имя шаблона проекта:', defaultName, defaultName, { okText: 'Сохранить' });
277
- if (!name) return;
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'ы (characters / locations / episodes).
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
- // 3. Для каждого board'а — читаем manifest, загружаем файлы, копим
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
- // 4. POST /api/templates с kind='project' и manifest.boards = [...].
343
- const r = await fetch('/api/templates', {
344
- method: 'POST',
345
- headers: { 'Content-Type': 'application/json' },
346
- body: JSON.stringify({
347
- name,
348
- kind: 'project',
349
- manifest: {
350
- boards: boardsPayload,
351
- projectName: state.filmHandle.name,
352
- ...(coverUrl ? { coverUrl } : {}),
353
- },
354
- files: {}, // всё внутри manifest.boards[i].files
355
- }),
356
- });
357
- if (!r.ok) {
358
- const err = await r.json().catch(() => ({}));
359
- throw new Error(err.error || `HTTP ${r.status}`);
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
- tplStatus(`Шаблон проекта «${name}» сохранён (${allBoards.length} board'ов, ${totalFiles} файл${totalFiles === 1 ? '' : 'ов'})`);
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
- if (!state.filmHandle) { alert('Сначала открой проект'); return; }
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
- const name = await askName(
525
- 'Имя для скачанной сцены:',
526
- suggestedName || 'из шаблона',
527
- suggestedName || '',
528
- { okText: 'Скачать и открыть' },
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('Получение шаблона…');