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.
@@ -0,0 +1,612 @@
1
+ // renderer/cloudProjects.js — облачные проекты (CRUD + sync с локальной копией).
2
+ //
3
+ // Концепция:
4
+ // - Облачный проект хранится на Chatium (heap-table t_spaces_server_projects_K1).
5
+ // - Локально живёт в `userData/cloud-projects/<projectId>/` (через cloudFs IPC).
6
+ // - state.filmHandle указывает на этот локальный фолдер через cloudFs-shim
7
+ // (см. renderer/cloudFs.js → makeCloudHandle), весь существующий код
8
+ // board.js / generate.js / state.js работает без изменений.
9
+ // - Любое локальное изменение НЕ синхронизируется на сервер автоматически —
10
+ // юзер должен нажать «☁ Сохранить на сервер».
11
+ //
12
+ // Lifecycle:
13
+ // 1. createNewCloudProject(name) → POST /api/projects → получаем id →
14
+ // ensureLocal(id) → openFilm(cloudHandle).
15
+ // 2. openCloudProject(id) → GET /api/projects/<id> → скачать все файлы
16
+ // с CDN в userData/cloud-projects/<id>/ → openFilm(cloudHandle).
17
+ // 3. saveCloudProject() → пройти по локальной копии → залить изменённые
18
+ // файлы (hash dedup через .kingkont-meta.json) → POST /api/projects~update.
19
+ //
20
+ // Маркеры:
21
+ // - state.cloudProjectId — id серверного проекта (если открыт облачный)
22
+ // - state.cloudDirty — есть несохранённые изменения
23
+
24
+ (function () {
25
+ // -------------- helpers --------------
26
+ function $(id) { return document.getElementById(id); }
27
+ function isLoggedIn(s) { return !!(s?.useChatium && s?.chatium?.token); }
28
+ async function getSettings() { try { return await window.appSettings?.get(); } catch { return {}; } }
29
+
30
+ // Прогресс — переиспользуем существующий tplProgress из templates.js.
31
+ const PROGRESS = (typeof TPL_PROGRESS !== 'undefined') ? TPL_PROGRESS : {
32
+ show() {}, update() {}, hide() {},
33
+ };
34
+
35
+ function setCloudButtonsVisibility() {
36
+ // Кнопки видны только если юзер залогинен в Chatium.
37
+ getSettings().then(s => {
38
+ const logged = isLoggedIn(s);
39
+ const inProject = !!state.filmHandle;
40
+ const newBtn = $('newCloudProject');
41
+ const openBtn = $('openCloudProjects');
42
+ const saveBtn = $('saveProjectCloud');
43
+ if (newBtn) newBtn.style.display = (logged && !inProject) ? '' : 'none';
44
+ if (openBtn) openBtn.style.display = (logged && !inProject) ? '' : 'none';
45
+ // Save-кнопка: видна для ЛЮБОГО открытого проекта (cloud или папочного).
46
+ if (saveBtn) saveBtn.style.display = (logged && inProject) ? '' : 'none';
47
+ if (saveBtn) {
48
+ // Cloud-проект → "☁ Сохранить на сервер"
49
+ // Папочный → "☁ Загрузить на сервер" (создаст или обновит шаблон-проект).
50
+ if (state.cloudProjectId) {
51
+ saveBtn.textContent = state.cloudDirty ? '☁ Сохранить на сервер •' : '☁ Сохранено';
52
+ saveBtn.disabled = false;
53
+ } else {
54
+ saveBtn.textContent = '☁ Сохранить как новый облачный';
55
+ saveBtn.disabled = false;
56
+ }
57
+ }
58
+ }).catch(() => {});
59
+ }
60
+
61
+ function markDirty() { state.cloudDirty = true; setCloudButtonsVisibility(); }
62
+ function markClean() { state.cloudDirty = false; setCloudButtonsVisibility(); }
63
+
64
+ // Перехват scheduleSave, чтобы помечать dirty для cloud-проектов.
65
+ // (scheduleSave определён в state.js; вызывается на любое изменение.)
66
+ if (typeof scheduleSave === 'function') {
67
+ const orig = scheduleSave;
68
+ window.scheduleSave = function () {
69
+ if (state.cloudProjectId) markDirty();
70
+ return orig.apply(this, arguments);
71
+ };
72
+ }
73
+
74
+ // -------------- список cloud-проектов: cached + background refresh --------------
75
+ // Хранится в localStorage:'cloudProjectsCache' = JSON-массив из /api/projects
76
+ // (тот же формат). Используется welcome-grid'ом, чтобы не ждать сетевого
77
+ // запроса при каждом открытии — UI показывает закэшированный список сразу,
78
+ // а свежий грузится фоном и обновляет grid через onChange колбек.
79
+ const CACHE_KEY = 'cloudProjectsCache';
80
+ function readCache() {
81
+ try {
82
+ const raw = localStorage.getItem(CACHE_KEY);
83
+ return raw ? JSON.parse(raw) : [];
84
+ } catch { return []; }
85
+ }
86
+ function writeCache(items) {
87
+ try { localStorage.setItem(CACHE_KEY, JSON.stringify(items)); } catch {}
88
+ }
89
+ // Subscribers: (items) => void.
90
+ const _listSubs = new Set();
91
+ function onListChange(cb) { _listSubs.add(cb); return () => _listSubs.delete(cb); }
92
+ function notifyListChange(items) { for (const cb of _listSubs) try { cb(items); } catch {} }
93
+
94
+ // Возвращает {cached: [...], promise: Promise<items>}.
95
+ // cached — синхронно из localStorage (для мгновенного render'а).
96
+ // promise — фоновый fetch, по завершении кэш обновлён + notifyListChange.
97
+ // Доп: к каждому item подмешиваем lastOpenedAt из локального хранилища —
98
+ // welcome сортирует cloud-карточки по max(updatedAt, lastOpenedAt), чтобы
99
+ // недавно открытые поднимались наверх (по аналогии с recents для папок).
100
+ const OPENED_KEY = 'cloudProjectsLastOpened';
101
+ function readOpenedMap() {
102
+ try { return JSON.parse(localStorage.getItem(OPENED_KEY) || '{}'); }
103
+ catch { return {}; }
104
+ }
105
+ function touchCloudOpened(projectId) {
106
+ if (!projectId) return;
107
+ const m = readOpenedMap();
108
+ m[projectId] = Date.now();
109
+ try { localStorage.setItem(OPENED_KEY, JSON.stringify(m)); } catch {}
110
+ // Обновляем cached entry, чтобы welcome перерисовался с новым order'ом.
111
+ const cached = readCache();
112
+ const i = cached.findIndex(c => c.id === projectId);
113
+ if (i >= 0) {
114
+ cached[i].lastOpenedAt = m[projectId];
115
+ writeCache(cached);
116
+ notifyListChange(cached);
117
+ }
118
+ }
119
+ function decorateWithOpened(items) {
120
+ const m = readOpenedMap();
121
+ return items.map(it => ({ ...it, lastOpenedAt: m[it.id] || 0 }));
122
+ }
123
+ function fetchListCached() {
124
+ const cached = decorateWithOpened(readCache());
125
+ const promise = (async () => {
126
+ const s = await getSettings();
127
+ if (!isLoggedIn(s)) return cached; // нет логина → не дёргаем
128
+ try {
129
+ const r = await fetch('/api/projects');
130
+ if (!r.ok) return cached;
131
+ const items = decorateWithOpened(await r.json());
132
+ writeCache(items);
133
+ notifyListChange(items);
134
+ return items;
135
+ } catch { return cached; }
136
+ })();
137
+ return { cached, promise };
138
+ }
139
+
140
+ // -------------- create new cloud project --------------
141
+ async function createNewCloudProject() {
142
+ const s = await getSettings();
143
+ if (!isLoggedIn(s)) { alert('Войдите в KingKont (Настройки → Chatium), чтобы создавать облачные проекты'); return; }
144
+ // window.prompt() в Electron подавлен — используем нашу askName из board.js.
145
+ const name = await askName('Название проекта:', '', 'Новый проект');
146
+ if (!name || !name.trim()) return;
147
+ try {
148
+ // 1) Создаём пустую запись на сервере.
149
+ const r = await fetch('/api/projects', {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ name: name.trim(), manifest: { boards: [] }, files: {} }),
153
+ });
154
+ if (!r.ok) {
155
+ const err = await r.json().catch(() => ({}));
156
+ throw new Error(err.error || `HTTP ${r.status}`);
157
+ }
158
+ const created = await r.json();
159
+ // 2) Локальный фолдер + cloud-handle.
160
+ const handle = await window.cloudFsShim.makeCloudHandle(created.id, name.trim());
161
+ // 3) Открываем как обычный проект (filmHandle).
162
+ state.cloudProjectId = created.id;
163
+ state.cloudDirty = false;
164
+ touchCloudOpened(created.id);
165
+ // Обновляем кэш списка (новый проект ещё не пришёл в /api/projects).
166
+ const cached = readCache();
167
+ cached.unshift({
168
+ id: created.id, name: created.name || name.trim(),
169
+ coverUrl: null, fileCount: 0, boardCount: 0,
170
+ updatedAt: created.createdAt || Date.now(),
171
+ createdAt: created.createdAt || Date.now(),
172
+ lastOpenedAt: Date.now(),
173
+ mine: true, canModify: true,
174
+ });
175
+ writeCache(cached);
176
+ notifyListChange(cached);
177
+ await openFilm(handle);
178
+ setCloudButtonsVisibility();
179
+ } catch (e) {
180
+ alert('Не удалось создать облачный проект: ' + (e?.message || e));
181
+ }
182
+ }
183
+
184
+ // -------------- open cloud project (использует local cache если есть) --------------
185
+ // Поведение:
186
+ // 1. Создаём/получаем local handle (через cloudFs shim).
187
+ // 2. Если local copy уже синхронизирована (.kingkont-meta.json
188
+ // содержит cloudProjectId === projectId) — открываем без сети.
189
+ // Юзер должен явно нажать «☁ Сохранить на сервер» чтобы запушить
190
+ // локальные изменения, ИЛИ ПКМ→«Обновить из облака» (refreshFromServer).
191
+ // 3. Если copy не синхронизирована (первый открытие) — скачиваем
192
+ // manifest + все файлы, потом открываем.
193
+ //
194
+ // Этот flow и кэш позволяют после первого скачивания работать с проектом
195
+ // моментально, как с папочным.
196
+ async function openCloudProject(projectId, suggestedName, opts = {}) {
197
+ const s = await getSettings();
198
+ if (!isLoggedIn(s)) { alert('Войдите в KingKont'); return; }
199
+ // 1. Проверяем синхронизирован ли уже локально.
200
+ if (window.cloudFs) {
201
+ try {
202
+ const metaText = await window.cloudFs.readText(projectId, '.kingkont-meta.json');
203
+ if (metaText && !opts.forceRefresh) {
204
+ const meta = JSON.parse(metaText);
205
+ if (meta.cloudProjectId === projectId) {
206
+ // Local copy готова — открываем без скачивания.
207
+ const handle = await window.cloudFsShim.makeCloudHandle(projectId, suggestedName || projectId);
208
+ state.cloudProjectId = projectId;
209
+ state.cloudDirty = false;
210
+ touchCloudOpened(projectId);
211
+ await openFilm(handle);
212
+ setCloudButtonsVisibility();
213
+ return;
214
+ }
215
+ }
216
+ } catch {}
217
+ }
218
+ // 2. Не синхронизирован → скачиваем с сервера.
219
+ PROGRESS.show('Загрузка проекта…');
220
+ try {
221
+ const r = await fetch('/api/projects/' + encodeURIComponent(projectId));
222
+ if (!r.ok) {
223
+ const err = await r.json().catch(() => ({}));
224
+ throw new Error(err.error || `HTTP ${r.status}`);
225
+ }
226
+ const proj = await r.json();
227
+ const handle = await window.cloudFsShim.makeCloudHandle(projectId, proj.name || suggestedName || 'Project');
228
+ // Скачиваем все board'ы манифеста (если они уже не лежат локально с тем же hash).
229
+ const boards = Array.isArray(proj.manifest?.boards) ? proj.manifest.boards : [];
230
+ // Сначала собираем общий список файлов для прогресса.
231
+ const totalFiles = boards.reduce((s, b) => s + Object.keys(b?.files || {}).length, 0);
232
+ let done = 0;
233
+ // fileHashes индексируем тем же ключом что использует save: `${kind}/${name}/${relPath}`.
234
+ // Это критично — иначе при следующем save dedup не найдёт ничего и
235
+ // перезальёт все файлы заново.
236
+ const fileHashes = {};
237
+ for (const board of boards) {
238
+ const boardDir = boardKindToDir(board.kind, board.name);
239
+ // 1) scene.json
240
+ if (board.scene) {
241
+ await writeCloudFile(projectId, joinPath(boardDir, 'scene.json'),
242
+ new TextEncoder().encode(JSON.stringify(board.scene, null, 2)));
243
+ }
244
+ // 2) media-файлы
245
+ for (const [relPath, cdnUrl] of Object.entries(board.files || {})) {
246
+ done++;
247
+ PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
248
+ const fr = await fetch('/api/proxy?url=' + encodeURIComponent(cdnUrl));
249
+ if (!fr.ok) throw new Error(`Не удалось скачать ${relPath}: ${fr.status}`);
250
+ const buf = new Uint8Array(await fr.arrayBuffer());
251
+ const localPath = joinPath(boardDir, relPath);
252
+ await writeCloudFile(projectId, localPath, buf);
253
+ // Получаем mtimeMs от FS — без него dedup на save не сработает
254
+ // (cached.mtime === file.lastModified должно совпадать).
255
+ let mtime = Date.now();
256
+ if (window.cloudFs?.stat) {
257
+ try {
258
+ const st = await window.cloudFs.stat(projectId, localPath);
259
+ if (st?.mtimeMs) mtime = st.mtimeMs;
260
+ } catch {}
261
+ }
262
+ fileHashes[`${board.kind}/${board.name}/${relPath}`] = {
263
+ url: cdnUrl, size: buf.byteLength, mtime,
264
+ };
265
+ }
266
+ }
267
+ // 3) cover (опционально). Запоминаем size+mtime → coverHash для
268
+ // dedup'а при следующем save'е (иначе первый же save перезальёт
269
+ // обложку, хотя она не менялась).
270
+ let openedCoverHash = null;
271
+ if (proj.coverUrl) {
272
+ try {
273
+ const fr = await fetch('/api/proxy?url=' + encodeURIComponent(proj.coverUrl));
274
+ if (fr.ok) {
275
+ const buf = new Uint8Array(await fr.arrayBuffer());
276
+ const ct = fr.headers.get('content-type') || 'image/jpeg';
277
+ const ext = ct.includes('png') ? 'png' : (ct.includes('webp') ? 'webp' : 'jpg');
278
+ const coverPath = '.cover.' + ext;
279
+ await writeCloudFile(projectId, coverPath, buf);
280
+ let coverMtime = Date.now();
281
+ if (window.cloudFs?.stat) {
282
+ try {
283
+ const st = await window.cloudFs.stat(projectId, coverPath);
284
+ if (st?.mtimeMs) coverMtime = st.mtimeMs;
285
+ } catch {}
286
+ }
287
+ openedCoverHash = { size: buf.byteLength, mtime: coverMtime, url: proj.coverUrl };
288
+ }
289
+ } catch {}
290
+ }
291
+ // 4) meta — cloudProjectId + fileHashes (собран в loop'е выше) + coverHash.
292
+ // Ключи в формате `${kind}/${name}/${relPath}` совпадают с тем что
293
+ // использует saveCloudProject — dedup сработает на следующем save'е.
294
+ await writeCloudFile(projectId, '.kingkont-meta.json',
295
+ new TextEncoder().encode(JSON.stringify({
296
+ cloudProjectId: projectId,
297
+ fileHashes,
298
+ coverUrl: proj.coverUrl || null,
299
+ coverHash: openedCoverHash,
300
+ syncedAt: Date.now(),
301
+ }, null, 2)));
302
+ // 5) Открываем.
303
+ state.cloudProjectId = projectId;
304
+ state.cloudDirty = false;
305
+ touchCloudOpened(projectId);
306
+ await openFilm(handle);
307
+ setCloudButtonsVisibility();
308
+ PROGRESS.hide();
309
+ } catch (e) {
310
+ PROGRESS.hide();
311
+ alert('Не удалось открыть облачный проект: ' + (e?.message || e));
312
+ }
313
+ }
314
+
315
+ function boardKindToDir(kind, name) {
316
+ if (kind === 'character') return '_characters/' + name;
317
+ if (kind === 'location') return '_locations/' + name;
318
+ return name; // scene/episode — корень проекта
319
+ }
320
+ function joinPath(a, b) { return a ? (a + '/' + b).replace(/\/+/g, '/') : b; }
321
+
322
+ async function writeCloudFile(projectId, relPath, data) {
323
+ if (window.cloudFs) return window.cloudFs.write(projectId, relPath, data);
324
+ // web fallback — пишем в backend in-memory.
325
+ // (cloudFsShim сам разруливает; этот path для main loop, не используется напрямую)
326
+ return false;
327
+ }
328
+
329
+ // -------------- save cloud project (upload changed files) --------------
330
+ async function saveCloudProject() {
331
+ const s = await getSettings();
332
+ if (!isLoggedIn(s)) { alert('Войдите в KingKont'); return; }
333
+ if (!state.filmHandle) return;
334
+
335
+ // Если это папочный (не cloud) проект — спросим имя и создадим новый
336
+ // cloud-проект. Это ответ на «Любой проект (в том числе тот что создан
337
+ // в папке) нужно уметь сохранить на сервер».
338
+ const isCloud = !!state.cloudProjectId;
339
+ let projectId = state.cloudProjectId;
340
+ let projectName = state.filmHandle.name;
341
+ if (!isCloud) {
342
+ // window.prompt() в Electron подавлен — askName из board.js.
343
+ const proposed = await askName('Сохранить проект на сервер. Название:', '', state.filmHandle.name);
344
+ if (!proposed || !proposed.trim()) return;
345
+ projectName = proposed.trim();
346
+ // Создаём пустую запись.
347
+ const r = await fetch('/api/projects', {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ name: projectName, manifest: { boards: [] }, files: {} }),
351
+ });
352
+ if (!r.ok) {
353
+ const err = await r.json().catch(() => ({}));
354
+ alert('Не удалось создать запись: ' + (err.error || r.status));
355
+ return;
356
+ }
357
+ const created = await r.json();
358
+ projectId = created.id;
359
+ state.cloudProjectId = projectId;
360
+ }
361
+
362
+ PROGRESS.show('Сбор данных проекта…');
363
+ try {
364
+ // Читаем .kingkont-meta.json — там лежат хеши (size+mtime) → cdnUrl
365
+ // для всех ранее загруженных файлов. Используем для dedup'а — не
366
+ // перезаливаем неизменённые файлы.
367
+ const meta = (typeof readProjectMeta === 'function')
368
+ ? (await readProjectMeta(state.filmHandle)) || {}
369
+ : {};
370
+ const oldHashes = meta.fileHashes || {};
371
+ const newHashes = {};
372
+
373
+ // 1) Собираем все board'ы локального проекта (та же логика что у
374
+ // saveCurrentProjectAsTemplate из templates.js).
375
+ const allBoards = await collectProjectBoards(state.filmHandle);
376
+ const manifestBoards = [];
377
+ const allFilesIndex = {};
378
+ let total = allBoards.reduce((s, b) => s + b.mediaFiles.length, 0);
379
+ let uploaded = 0;
380
+ let reused = 0;
381
+
382
+ for (const board of allBoards) {
383
+ const sceneFile = await board.handle.getFileHandle('scene.json').catch(() => null);
384
+ let scene = {};
385
+ if (sceneFile) {
386
+ const text = await (await sceneFile.getFile()).text();
387
+ try { scene = JSON.parse(text); } catch {}
388
+ }
389
+ const boardFiles = {};
390
+ // walkBoardFiles возвращает {relPath, fileHandle}.
391
+ for (const f of board.mediaFiles) {
392
+ if (!f.fileHandle) {
393
+ console.warn('skip file without fileHandle:', f);
394
+ continue;
395
+ }
396
+ const file = await f.fileHandle.getFile();
397
+ const metaKey = `${board.kind}/${board.name}/${f.relPath}`;
398
+ const cached = oldHashes[metaKey];
399
+ // Dedup: size + mtime совпадают → переиспользуем CDN-URL.
400
+ // (Это identifier «файл не менялся с момента последней синхронизации»;
401
+ // неважно загружали мы его сами или скачали при openCloudProject.)
402
+ if (cached && cached.size === file.size && cached.mtime === file.lastModified) {
403
+ boardFiles[f.relPath] = cached.url;
404
+ allFilesIndex[metaKey] = cached.url;
405
+ newHashes[metaKey] = cached;
406
+ reused++;
407
+ uploaded++;
408
+ PROGRESS.update(uploaded, total,
409
+ `[${board.kind}/${board.name}] ${f.relPath} (cached, ${uploaded}/${total})`);
410
+ continue;
411
+ }
412
+ PROGRESS.update(uploaded, total,
413
+ `[${board.kind}/${board.name}] ${f.relPath} (${uploaded + 1}/${total})`);
414
+ const r = await fetch('/api/upload', {
415
+ method: 'POST',
416
+ headers: {
417
+ 'Content-Type': file.type || 'application/octet-stream',
418
+ 'X-File-Name': encodeURIComponent(f.relPath.split('/').pop()),
419
+ },
420
+ body: await file.arrayBuffer(),
421
+ });
422
+ if (!r.ok) throw new Error(`upload ${f.relPath} failed: ${r.status}`);
423
+ const j = await r.json();
424
+ boardFiles[f.relPath] = j.url;
425
+ allFilesIndex[metaKey] = j.url;
426
+ newHashes[metaKey] = { url: j.url, size: file.size, mtime: file.lastModified };
427
+ uploaded++;
428
+ }
429
+ manifestBoards.push({ kind: board.kind, name: board.name, scene, files: boardFiles });
430
+ }
431
+
432
+ // 2) Cover (dedup'нутый — не грузим если size+mtime совпадают).
433
+ // meta.coverHash = { size, mtime, url } сохраняется после каждого upload'а.
434
+ let coverUrl = meta.coverUrl || null;
435
+ let newCoverHash = meta.coverHash || null;
436
+ if (typeof readProjectCover === 'function') {
437
+ const coverBlob = await readProjectCover(state.filmHandle);
438
+ if (coverBlob) {
439
+ const ch = meta.coverHash;
440
+ if (ch && ch.size === coverBlob.size && ch.mtime === coverBlob.lastModified && ch.url) {
441
+ // Cover не менялся — переиспользуем CDN-URL.
442
+ coverUrl = ch.url;
443
+ PROGRESS.update(uploaded, total, 'Обложка не менялась (cached)');
444
+ } else {
445
+ PROGRESS.update(uploaded, total, 'Загрузка обложки…');
446
+ const r = await fetch('/api/upload', {
447
+ method: 'POST',
448
+ headers: {
449
+ 'Content-Type': coverBlob.type || 'application/octet-stream',
450
+ 'X-File-Name': encodeURIComponent('cover.' + (coverBlob.type?.split('/')[1] || 'jpg')),
451
+ },
452
+ body: await coverBlob.arrayBuffer(),
453
+ });
454
+ if (r.ok) {
455
+ const { url } = await r.json();
456
+ coverUrl = url;
457
+ newCoverHash = { size: coverBlob.size, mtime: coverBlob.lastModified, url };
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ // 3) Update server record.
464
+ PROGRESS.update(uploaded, total, 'Сохранение на сервере…');
465
+ const updateR = await fetch('/api/projects/' + encodeURIComponent(projectId), {
466
+ method: 'POST',
467
+ headers: { 'Content-Type': 'application/json' },
468
+ body: JSON.stringify({
469
+ name: projectName,
470
+ manifest: { boards: manifestBoards },
471
+ files: allFilesIndex,
472
+ coverUrl: coverUrl || '',
473
+ }),
474
+ });
475
+ if (!updateR.ok) {
476
+ const err = await updateR.json().catch(() => ({}));
477
+ throw new Error(err.error || `HTTP ${updateR.status}`);
478
+ }
479
+
480
+ // 4) Перезаписать meta с обновлёнными хешами для следующего save'а.
481
+ if (typeof writeProjectMeta === 'function') {
482
+ await writeProjectMeta(state.filmHandle, {
483
+ ...meta,
484
+ cloudProjectId: projectId,
485
+ fileHashes: newHashes,
486
+ coverUrl: coverUrl || null,
487
+ coverHash: newCoverHash,
488
+ syncedAt: Date.now(),
489
+ });
490
+ }
491
+
492
+ PROGRESS.hide();
493
+ markClean();
494
+ const stats = reused
495
+ ? `${uploaded - reused} новых, ${reused} переиспользованы`
496
+ : `${uploaded} файлов`;
497
+ alert(`Проект «${projectName}» сохранён (${stats}).`);
498
+ } catch (e) {
499
+ PROGRESS.hide();
500
+ alert('Ошибка сохранения: ' + (e?.message || e));
501
+ }
502
+ }
503
+
504
+ // -------------- list modal --------------
505
+ async function openCloudProjectsModal() {
506
+ const s = await getSettings();
507
+ if (!isLoggedIn(s)) { alert('Войдите в KingKont'); return; }
508
+ // Переиспользуем templatesModal — но рисуем туда список проектов вместо
509
+ // шаблонов. Подсказываем что это другой контент.
510
+ const modal = $('templatesModal');
511
+ if (!modal) return;
512
+ modal.classList.remove('hidden');
513
+ // Скрываем кнопку «+ Проект» (она для шаблонов), показываем нашу.
514
+ const tplSaveProj = $('tplSaveProject');
515
+ if (tplSaveProj) tplSaveProj.style.display = 'none';
516
+ const list = $('tplList');
517
+ list.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Загрузка…</div>';
518
+ try {
519
+ const r = await fetch('/api/projects');
520
+ if (!r.ok) {
521
+ const err = await r.json().catch(() => ({}));
522
+ throw new Error(err.error || `HTTP ${r.status}`);
523
+ }
524
+ const items = await r.json();
525
+ list.innerHTML = '';
526
+ if (!items.length) {
527
+ list.innerHTML = '<div style="padding:24px; text-align:center; color:#888;">Облачных проектов пока нет. Создай новый кнопкой «☁ Новый проект (облако)».</div>';
528
+ return;
529
+ }
530
+ for (const p of items) {
531
+ const card = document.createElement('div');
532
+ card.style.cssText = 'display:flex; gap:12px; align-items:center; padding:10px 12px; background:#1a1a1a; border:1px solid #333; border-radius:6px;';
533
+ const cover = document.createElement('div');
534
+ cover.style.cssText = 'width:80px; height:60px; flex-shrink:0; border-radius:4px; background:#0e0e0e; border:1px solid #2a2a2a; overflow:hidden; display:flex; align-items:center; justify-content:center; color:#444; font-size:24px;';
535
+ if (p.coverUrl) {
536
+ const img = document.createElement('img');
537
+ img.style.cssText = 'width:100%; height:100%; object-fit:cover;';
538
+ img.src = '/api/proxy?url=' + encodeURIComponent(p.coverUrl);
539
+ img.draggable = false;
540
+ img.onerror = () => { cover.textContent = '🎬'; };
541
+ cover.appendChild(img);
542
+ } else cover.textContent = '🎬';
543
+ card.appendChild(cover);
544
+
545
+ const info = document.createElement('div');
546
+ info.style.cssText = 'flex:1; min-width:0;';
547
+ const title = document.createElement('div');
548
+ title.style.cssText = 'font-size:14px; color:#e0e0e0; font-weight:500; word-break:break-word;';
549
+ title.textContent = p.name || '(без названия)';
550
+ const meta = document.createElement('div');
551
+ meta.style.cssText = 'font-size:11px; color:#888; margin-top:4px;';
552
+ meta.textContent = [
553
+ p.boardCount ? `${p.boardCount} board(s)` : null,
554
+ p.fileCount ? `${p.fileCount} файлов` : null,
555
+ p.updatedAt ? 'обновлён ' + new Date(p.updatedAt).toLocaleString('ru-RU') : null,
556
+ ].filter(Boolean).join(' · ');
557
+ info.append(title, meta);
558
+
559
+ const openBtn = document.createElement('button');
560
+ openBtn.className = 'primary';
561
+ openBtn.textContent = 'Открыть';
562
+ openBtn.addEventListener('click', () => {
563
+ modal.classList.add('hidden');
564
+ openCloudProject(p.id, p.name);
565
+ });
566
+ const delBtn = document.createElement('button');
567
+ delBtn.className = 'danger';
568
+ delBtn.textContent = '🗑';
569
+ delBtn.title = 'Удалить (только серверная копия, локальный кэш можно очистить вручную)';
570
+ delBtn.addEventListener('click', async () => {
571
+ if (!confirm(`Удалить «${p.name}» с сервера? Локальная копия в userData останется.`)) return;
572
+ try {
573
+ const dr = await fetch('/api/projects/' + encodeURIComponent(p.id), { method: 'DELETE' });
574
+ if (!dr.ok) throw new Error('HTTP ' + dr.status);
575
+ openCloudProjectsModal(); // refresh
576
+ } catch (e) { alert('Не удалось удалить: ' + (e?.message || e)); }
577
+ });
578
+
579
+ card.append(info, openBtn, delBtn);
580
+ list.appendChild(card);
581
+ }
582
+ } catch (e) {
583
+ list.innerHTML = `<div style="padding:24px; text-align:center; color:#f88;">Ошибка: ${e.message}</div>`;
584
+ }
585
+ }
586
+
587
+ // -------------- wire buttons --------------
588
+ document.addEventListener('DOMContentLoaded', () => {
589
+ $('newCloudProject')?.addEventListener('click', createNewCloudProject);
590
+ $('openCloudProjects')?.addEventListener('click', openCloudProjectsModal);
591
+ $('saveProjectCloud')?.addEventListener('click', saveCloudProject);
592
+ setCloudButtonsVisibility();
593
+ // Переинициализируем видимость кнопок раз в 5 сек — для случая когда юзер
594
+ // login/logout в Chatium через настройки (нет других сигналов).
595
+ setInterval(setCloudButtonsVisibility, 5000);
596
+ });
597
+
598
+ // Вызывается из openFilm / closeProject (через хук в board.js).
599
+ window.cloudProjects = {
600
+ setVisibility: setCloudButtonsVisibility,
601
+ markDirty,
602
+ markClean,
603
+ createNew: createNewCloudProject,
604
+ open: openCloudProject,
605
+ save: saveCloudProject,
606
+ openListModal: openCloudProjectsModal,
607
+ // Для welcome-grid'а: cached список + onChange-подписка.
608
+ fetchListCached,
609
+ onListChange,
610
+ readCache,
611
+ };
612
+ })();
@@ -1836,6 +1836,13 @@ async function startGenerationJob(node, kind, prompt, mediaRefs, boardHandle, bK
1836
1836
  logJob(node.id, `generate ERROR: ${data.error || rawText.slice(0,200)}`);
1837
1837
  throw new Error(data.error || `HTTP ${r.status}: ${rawText.slice(0,200)}`);
1838
1838
  }
1839
+ // Защита от bug'а где провайдер вернул success без taskId:
1840
+ // дальше pollJob будет дёргать /api/poll?taskId=undefined → KIE
1841
+ // ответит «task id is blank», запутывая логи. Лучше fail сразу.
1842
+ if (!data.taskId) {
1843
+ logJob(node.id, `generate ERROR: провайдер вернул пустой taskId. Тело: ${rawText.slice(0,300)}`);
1844
+ throw new Error('Провайдер не вернул taskId. Проверь модель/параметры (см. лог).');
1845
+ }
1839
1846
  job.taskId = data.taskId;
1840
1847
  logJob(node.id, `taskId=${data.taskId}`);
1841
1848
  await mutateNode(bKey, boardHandle, node.id, n => {