sanjang 0.3.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/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/__tests__/sanjang.test.ts +42 -0
- package/bin/sanjang.js +17 -0
- package/bin/sanjang.ts +144 -0
- package/dashboard/app.js +1888 -0
- package/dashboard/app.test.js +2 -0
- package/dashboard/index.html +275 -0
- package/dashboard/style.css +2112 -0
- package/lib/config.ts +337 -0
- package/lib/engine/cache.ts +218 -0
- package/lib/engine/config-hotfix.ts +161 -0
- package/lib/engine/conflict.ts +33 -0
- package/lib/engine/diagnostics.ts +81 -0
- package/lib/engine/naming.ts +93 -0
- package/lib/engine/ports.ts +61 -0
- package/lib/engine/pr.ts +71 -0
- package/lib/engine/process.ts +283 -0
- package/lib/engine/self-heal.ts +130 -0
- package/lib/engine/smart-init.ts +136 -0
- package/lib/engine/smart-pr.ts +130 -0
- package/lib/engine/snapshot.ts +45 -0
- package/lib/engine/state.ts +60 -0
- package/lib/engine/suggest.ts +169 -0
- package/lib/engine/warp.ts +47 -0
- package/lib/engine/watcher.ts +40 -0
- package/lib/engine/worktree.ts +100 -0
- package/lib/server.ts +1560 -0
- package/lib/types.ts +130 -0
- package/package.json +48 -0
- package/templates/sanjang.config.js +32 -0
package/dashboard/app.js
ADDED
|
@@ -0,0 +1,1888 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
산장 Dashboard — Client Logic
|
|
3
|
+
============================================================ */
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// State
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** @type {Map<string, { name: string, branch: string, status: string, fePort?: number, bePort?: number, slot?: number }>} */
|
|
10
|
+
const playgrounds = new Map();
|
|
11
|
+
|
|
12
|
+
/** @type {Map<string, Array<{text: string, source: string}>>} logs keyed by playground name */
|
|
13
|
+
const logs = new Map();
|
|
14
|
+
|
|
15
|
+
/** @type {Map<string, Array>} diagnostics keyed by playground name */
|
|
16
|
+
const diagnostics = new Map();
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** @type {string|null} name of camp in workspace view, or null for list view */
|
|
20
|
+
let currentWorkspace = null;
|
|
21
|
+
|
|
22
|
+
/** @type {number|null} polling interval for workspace changes */
|
|
23
|
+
let wsPollingInterval = null;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Utility
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Escape HTML to prevent XSS.
|
|
31
|
+
* @param {string} str
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function escHtml(str) {
|
|
35
|
+
return String(str)
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, ''');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Show a toast notification.
|
|
45
|
+
* @param {string} message
|
|
46
|
+
* @param {'info'|'success'|'error'} type
|
|
47
|
+
*/
|
|
48
|
+
function toast(message, type = 'info') {
|
|
49
|
+
const container = document.getElementById('toast-container');
|
|
50
|
+
const el = document.createElement('div');
|
|
51
|
+
el.className = 'toast' + (type === 'error' ? ' toast-error' : type === 'success' ? ' toast-success' : '');
|
|
52
|
+
el.textContent = message;
|
|
53
|
+
container.appendChild(el);
|
|
54
|
+
setTimeout(() => el.remove(), 3000);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// API
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch wrapper.
|
|
63
|
+
* @param {string} method
|
|
64
|
+
* @param {string} path
|
|
65
|
+
* @param {object|null} [body]
|
|
66
|
+
* @returns {Promise<any>}
|
|
67
|
+
*/
|
|
68
|
+
async function api(method, path, body = null) {
|
|
69
|
+
const opts = {
|
|
70
|
+
method,
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
};
|
|
73
|
+
if (body !== null) opts.body = JSON.stringify(body);
|
|
74
|
+
const res = await fetch(path, opts);
|
|
75
|
+
const data = await res.json().catch(() => ({}));
|
|
76
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// WebSocket
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
let ws = null;
|
|
85
|
+
|
|
86
|
+
function connectWs() {
|
|
87
|
+
const url = `ws://${location.host}`;
|
|
88
|
+
ws = new WebSocket(url);
|
|
89
|
+
|
|
90
|
+
ws.addEventListener('message', (evt) => {
|
|
91
|
+
let msg;
|
|
92
|
+
try { msg = JSON.parse(evt.data); } catch { return; }
|
|
93
|
+
handleWsMessage(msg);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ws.addEventListener('close', () => {
|
|
97
|
+
setTimeout(connectWs, 2000);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
ws.addEventListener('error', () => {
|
|
101
|
+
ws.close();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handle incoming WebSocket message.
|
|
107
|
+
* @param {{ type: string, name?: string, data?: any, source?: string }} msg
|
|
108
|
+
*/
|
|
109
|
+
function handleWsMessage(msg) {
|
|
110
|
+
const { type, name, data, source } = msg;
|
|
111
|
+
|
|
112
|
+
switch (type) {
|
|
113
|
+
case 'log': {
|
|
114
|
+
if (!name) break;
|
|
115
|
+
if (!logs.has(name)) logs.set(name, []);
|
|
116
|
+
const lines = logs.get(name);
|
|
117
|
+
lines.push({ text: data, source: source ?? 'be' });
|
|
118
|
+
if (lines.length > 50) lines.splice(0, lines.length - 50);
|
|
119
|
+
updateLogPanel(name);
|
|
120
|
+
if (currentWorkspace === name) updateWorkspaceLog(name);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'playground-status': {
|
|
125
|
+
if (!name) break;
|
|
126
|
+
const pg = playgrounds.get(name);
|
|
127
|
+
if (pg) {
|
|
128
|
+
playgrounds.set(name, { ...pg, ...data });
|
|
129
|
+
renderAll();
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'playground-diagnostics': {
|
|
135
|
+
if (!name) break;
|
|
136
|
+
diagnostics.set(name, data ?? []);
|
|
137
|
+
updateDiagPanel(name);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'playground-created': {
|
|
142
|
+
if (!name || !data) break;
|
|
143
|
+
playgrounds.set(name, data);
|
|
144
|
+
renderAll();
|
|
145
|
+
toast(`캠프 "${name}" 생성됨`, 'success');
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'playground-deleted': {
|
|
150
|
+
if (!name) break;
|
|
151
|
+
playgrounds.delete(name);
|
|
152
|
+
logs.delete(name);
|
|
153
|
+
diagnostics.delete(name);
|
|
154
|
+
renderAll();
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
case 'reset': {
|
|
160
|
+
if (!name) break;
|
|
161
|
+
toast(`캠프 "${name}" 초기화 완료`, 'success');
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'playground-pr-created': {
|
|
166
|
+
if (!name) break;
|
|
167
|
+
// PR URL arrived from background — update result modal
|
|
168
|
+
const prContent = document.getElementById('ship-result-content');
|
|
169
|
+
if (prContent) {
|
|
170
|
+
prContent.innerHTML = `
|
|
171
|
+
<p style="margin-bottom:12px">PR이 만들어졌습니다!</p>
|
|
172
|
+
<a href="${escHtml(data?.prUrl || '')}" target="_blank" class="btn btn-primary"
|
|
173
|
+
style="display:inline-block;text-decoration:none">
|
|
174
|
+
PR 보기 →
|
|
175
|
+
</a>
|
|
176
|
+
<p style="margin-top:12px;font-size:12px;color:var(--text-muted)">
|
|
177
|
+
팀원이 확인하고 반영할 거예요.
|
|
178
|
+
</p>`;
|
|
179
|
+
}
|
|
180
|
+
document.getElementById('ship-result-modal').classList.add('open');
|
|
181
|
+
toast(`PR이 만들어졌습니다!`, 'success');
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case 'conflict-resolved': {
|
|
186
|
+
toast(`충돌이 해결되었습니다!`, 'success');
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'conflict-failed': {
|
|
191
|
+
toast(data?.message || '충돌 해결에 실패했습니다.', 'error');
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'playground-saved': {
|
|
196
|
+
if (!name) break;
|
|
197
|
+
toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'autosaved': {
|
|
202
|
+
if (!name) break;
|
|
203
|
+
toast('💾 오토세이브 완료', 'success');
|
|
204
|
+
if (currentWorkspace === name) {
|
|
205
|
+
api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'browser-error': {
|
|
211
|
+
if (!name || !data) break;
|
|
212
|
+
if (currentWorkspace !== name) break;
|
|
213
|
+
addBrowserError(data);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'file-changes': {
|
|
218
|
+
if (!name || !data) break;
|
|
219
|
+
if (currentWorkspace !== name) break;
|
|
220
|
+
|
|
221
|
+
const changesEl2 = document.getElementById('ws-changes');
|
|
222
|
+
const summaryText2 = document.getElementById('ws-changes-summary-text');
|
|
223
|
+
if (!changesEl2) break;
|
|
224
|
+
|
|
225
|
+
const prevPaths = new Set(
|
|
226
|
+
[...changesEl2.querySelectorAll('.ws-file-item span:last-child')].map(el => el.textContent)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (data.count === 0) {
|
|
230
|
+
if (summaryText2) summaryText2.textContent = '변경 없음';
|
|
231
|
+
changesEl2.innerHTML = '';
|
|
232
|
+
renderBlocks([]);
|
|
233
|
+
} else {
|
|
234
|
+
if (summaryText2) summaryText2.textContent = `${data.count}개 파일 변경됨`;
|
|
235
|
+
changesEl2.innerHTML = data.files.map(f => {
|
|
236
|
+
const isNew = !prevPaths.has(f.path);
|
|
237
|
+
return `<div class="ws-file-item${isNew ? ' ws-file-new' : ''}">
|
|
238
|
+
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
239
|
+
<span>${escHtml(f.path)}</span>
|
|
240
|
+
</div>`;
|
|
241
|
+
}).join('');
|
|
242
|
+
renderBlocks(data.files);
|
|
243
|
+
// Debounced AI summary fetch
|
|
244
|
+
debounceSummaryFetch(name);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
updateChangeSummary(data.count, data.ts);
|
|
248
|
+
updateMiniChar('running', data.count);
|
|
249
|
+
debouncePreviewRefresh();
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Render
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function renderAll() {
|
|
260
|
+
const grid = document.getElementById('grid');
|
|
261
|
+
|
|
262
|
+
// Show/hide the portal camps section based on whether camps exist
|
|
263
|
+
const campsSection = document.getElementById('portal-camps-section');
|
|
264
|
+
if (campsSection) {
|
|
265
|
+
if (playgrounds.size > 0) {
|
|
266
|
+
campsSection.classList.remove('hidden');
|
|
267
|
+
} else {
|
|
268
|
+
campsSection.classList.add('hidden');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (playgrounds.size === 0) {
|
|
273
|
+
grid.innerHTML = '';
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Build a map of existing cards
|
|
278
|
+
const existingCards = new Map();
|
|
279
|
+
for (const card of grid.querySelectorAll('.card[data-name]')) {
|
|
280
|
+
existingCards.set(card.dataset.name, card);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const names = [...playgrounds.keys()];
|
|
284
|
+
|
|
285
|
+
// Remove cards for deleted playgrounds
|
|
286
|
+
for (const [name, card] of existingCards) {
|
|
287
|
+
if (!playgrounds.has(name)) card.remove();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Add/update cards
|
|
291
|
+
for (const name of names) {
|
|
292
|
+
const pg = playgrounds.get(name);
|
|
293
|
+
const html = renderCard(pg);
|
|
294
|
+
const existing = existingCards.get(name);
|
|
295
|
+
if (existing) {
|
|
296
|
+
// Preserve log panel open state before replacing
|
|
297
|
+
const logOpen = existing.querySelector('.log-panel')?.classList.contains('open');
|
|
298
|
+
const tmp = document.createElement('div');
|
|
299
|
+
tmp.innerHTML = html;
|
|
300
|
+
const newCard = tmp.firstElementChild;
|
|
301
|
+
if (logOpen) {
|
|
302
|
+
newCard.querySelector('.log-panel')?.classList.add('open');
|
|
303
|
+
newCard.querySelector('.log-toggle')?.classList.add('open');
|
|
304
|
+
}
|
|
305
|
+
existing.replaceWith(newCard);
|
|
306
|
+
} else {
|
|
307
|
+
const tmp = document.createElement('div');
|
|
308
|
+
tmp.innerHTML = html;
|
|
309
|
+
grid.appendChild(tmp.firstElementChild);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Refresh log, diag, and changes panels
|
|
314
|
+
for (const name of names) {
|
|
315
|
+
updateLogPanel(name);
|
|
316
|
+
updateDiagPanel(name);
|
|
317
|
+
refreshChanges(name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Auto-fetch diagnostics for error-status camps
|
|
321
|
+
for (const [name, pg] of playgrounds) {
|
|
322
|
+
if (pg.status === 'error' && !diagnostics.has(name)) {
|
|
323
|
+
api('GET', `/api/playgrounds/${name}/diagnostics`).then(data => {
|
|
324
|
+
diagnostics.set(name, data);
|
|
325
|
+
updateDiagPanel(name);
|
|
326
|
+
}).catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Build the HTML string for a single playground card.
|
|
333
|
+
* @param {{ name: string, branch: string, status: string, fePort?: number, bePort?: number }} pg
|
|
334
|
+
* @returns {string}
|
|
335
|
+
*/
|
|
336
|
+
function renderCard(pg) {
|
|
337
|
+
const { name, branch, status, fePort, bePort } = pg;
|
|
338
|
+
const n = escHtml(name);
|
|
339
|
+
const b = escHtml(branch);
|
|
340
|
+
|
|
341
|
+
const badgeClass = `badge badge-${status}`;
|
|
342
|
+
const statusLabel = escHtml(status);
|
|
343
|
+
|
|
344
|
+
// (URLs are now inline in the card template)
|
|
345
|
+
|
|
346
|
+
const isStopped = status === 'stopped' || status === 'error';
|
|
347
|
+
const isRunning = status === 'running';
|
|
348
|
+
const isStarting = status === 'starting';
|
|
349
|
+
const canStop = isRunning || isStarting;
|
|
350
|
+
|
|
351
|
+
const diagPanelClass = diagnostics.has(name) && diagnostics.get(name).length > 0
|
|
352
|
+
? 'diag-panel'
|
|
353
|
+
: 'diag-panel hidden';
|
|
354
|
+
|
|
355
|
+
const pixelState = status === 'running' ? 'running'
|
|
356
|
+
: status === 'starting' ? 'starting'
|
|
357
|
+
: status === 'error' ? 'error'
|
|
358
|
+
: 'stopped';
|
|
359
|
+
|
|
360
|
+
const bubbles = {
|
|
361
|
+
running: ['정상까지 얼마 안 남았다!', '한 걸음씩...', '경치 좋다~', '배고프다...', '오늘 안에 되겠지?', '커밋 냄새가 난다', '산이 높을수록 뷰가 좋지', '거의 다 왔어!'],
|
|
362
|
+
starting: ['불 좀 피우는 중...', '따뜻해지면 출발!', '잠깐만 준비중~', '텐트 어디뒀지', '커피 한 잔만...', '워밍업 중!', '곧 간다곧 가~'],
|
|
363
|
+
stopped: ['zzZ...', '좋은 꿈...', '내일 하자...', '5분만 더...', '푹 자는 중', '꿈에서 코딩중', '알람 끄기...'],
|
|
364
|
+
error: ['살려줘ㅠ', '길을 잃었어...', '누가 좀!!', '여기 어디야', '구조대 불러줘', 'SOS!!!', '미끄러졌다ㅠ', '헬프미...']
|
|
365
|
+
};
|
|
366
|
+
const bubble = bubbles[pixelState][Math.floor(Math.random() * bubbles[pixelState].length)];
|
|
367
|
+
const sceneClass = pixelState === 'stopped' ? 'card-scene-stars' : 'card-scene-mountains';
|
|
368
|
+
const zzzHtml = pixelState === 'stopped' ? '<span class="camp-zzz">z z z</span>' : '';
|
|
369
|
+
|
|
370
|
+
const statusKo = { running: '실행 중', starting: '준비 중', stopped: '대기 중', error: '문제 발생', 'setting-up': '설치 중' };
|
|
371
|
+
const statusText = statusKo[status] || status;
|
|
372
|
+
|
|
373
|
+
// Simplified card — just a "door" to enter the workspace
|
|
374
|
+
const mainAction = status === 'error'
|
|
375
|
+
? `<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();autoFix('${n}')">자동으로 고치기</button>`
|
|
376
|
+
: isStopped
|
|
377
|
+
? `<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();startPg('${n}')">시작</button>`
|
|
378
|
+
: isRunning
|
|
379
|
+
? `<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();enterWorkspace('${n}')">들어가기</button>`
|
|
380
|
+
: '';
|
|
381
|
+
|
|
382
|
+
return `
|
|
383
|
+
<div class="card" data-name="${n}" onclick="enterWorkspace('${n}')" style="cursor:pointer">
|
|
384
|
+
<div class="card-scene ${sceneClass}"></div>
|
|
385
|
+
<div class="card-header">
|
|
386
|
+
<div class="card-header-left">
|
|
387
|
+
<div class="camp-avatar">
|
|
388
|
+
<div class="camp-bubble">${bubble}</div>
|
|
389
|
+
<div class="camp-pixel-wrap">
|
|
390
|
+
<div class="camp-pixel camp-pixel-${pixelState}"></div>
|
|
391
|
+
</div>
|
|
392
|
+
${zzzHtml}
|
|
393
|
+
</div>
|
|
394
|
+
<div class="card-header-text">
|
|
395
|
+
<span class="card-name">${n}</span>
|
|
396
|
+
<span class="card-branch">${statusText}</span>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="card-header-right">
|
|
400
|
+
${mainAction}
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</div>`.trim();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Re-render the log panel for a playground (preserves open state).
|
|
408
|
+
* @param {string} name
|
|
409
|
+
*/
|
|
410
|
+
function updateLogPanel(name) {
|
|
411
|
+
const panel = document.getElementById(`log-${name}`);
|
|
412
|
+
if (!panel) return;
|
|
413
|
+
const pre = panel.querySelector('pre');
|
|
414
|
+
if (!pre) return;
|
|
415
|
+
|
|
416
|
+
const lines = logs.get(name) ?? [];
|
|
417
|
+
pre.innerHTML = lines.map(({ text, source }) => {
|
|
418
|
+
const cls = source === 'fe' ? 'log-line-fe'
|
|
419
|
+
: source === 'be' ? 'log-line-be'
|
|
420
|
+
: source === 'task' ? 'log-line-task'
|
|
421
|
+
: 'log-line-err';
|
|
422
|
+
return `<span class="${cls}">${escHtml(text)}</span>`;
|
|
423
|
+
}).join('\n');
|
|
424
|
+
|
|
425
|
+
// Auto-scroll if already open
|
|
426
|
+
if (panel.classList.contains('open')) {
|
|
427
|
+
panel.scrollTop = panel.scrollHeight;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Re-render the diagnostics panel for a playground.
|
|
433
|
+
* @param {string} name
|
|
434
|
+
*/
|
|
435
|
+
function updateDiagPanel(name) {
|
|
436
|
+
const panel = document.getElementById(`diag-${name}`);
|
|
437
|
+
if (!panel) return;
|
|
438
|
+
|
|
439
|
+
const items = diagnostics.get(name) ?? [];
|
|
440
|
+
if (items.length === 0) {
|
|
441
|
+
panel.classList.add('hidden');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
panel.classList.remove('hidden');
|
|
446
|
+
|
|
447
|
+
const rows = items.map((item) => {
|
|
448
|
+
const icon = item.status === 'ok' ? '✅' : item.status === 'error' ? '❌' : 'ℹ️';
|
|
449
|
+
const guideHtml = item.guide
|
|
450
|
+
? `<div class="diag-guide">${escHtml(item.guide)}</div>`
|
|
451
|
+
: '';
|
|
452
|
+
return `<div class="diag-item">
|
|
453
|
+
<span class="diag-icon">${icon}</span>
|
|
454
|
+
<span class="diag-text"><strong>${escHtml(item.name ?? '')}</strong>${item.detail ? ' — ' + escHtml(item.detail) : ''}</span>
|
|
455
|
+
${guideHtml}
|
|
456
|
+
</div>`;
|
|
457
|
+
}).join('');
|
|
458
|
+
|
|
459
|
+
panel.innerHTML = `<div class="diag-title">무슨 일이 일어났나요?</div>${rows}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Log toggle
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
window.toggleLog = function toggleLog(name, toggleEl) {
|
|
467
|
+
const panel = document.getElementById(`log-${name}`);
|
|
468
|
+
if (!panel) return;
|
|
469
|
+
const isOpen = panel.classList.toggle('open');
|
|
470
|
+
toggleEl.classList.toggle('open', isOpen);
|
|
471
|
+
if (isOpen) {
|
|
472
|
+
updateLogPanel(name);
|
|
473
|
+
panel.scrollTop = panel.scrollHeight;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Actions
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
window.startPg = async function startPg(name) {
|
|
482
|
+
try {
|
|
483
|
+
await api('POST', `/api/playgrounds/${name}/start`);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
toast(`Start failed: ${err.message}`, 'error');
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
window.stopPg = async function stopPg(name) {
|
|
490
|
+
try {
|
|
491
|
+
await api('POST', `/api/playgrounds/${name}/stop`);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
toast(`Stop failed: ${err.message}`, 'error');
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
window.deletePg = async function deletePg(name) {
|
|
498
|
+
if (!confirm(`캠프 "${name}"을(를) 삭제할까요? 되돌릴 수 없습니다.`)) return;
|
|
499
|
+
try {
|
|
500
|
+
await api('DELETE', `/api/playgrounds/${name}`);
|
|
501
|
+
toast(`Deleted "${name}"`, 'success');
|
|
502
|
+
} catch (err) {
|
|
503
|
+
toast(`Delete failed: ${err.message}`, 'error');
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
window.resetPg = async function resetPg(name) {
|
|
508
|
+
if (!confirm('모든 변경 사항을 버리고 원래 상태로 돌아갑니다.\n\n되돌릴 수 없습니다. 계속할까요?')) return;
|
|
509
|
+
try {
|
|
510
|
+
await api('POST', `/api/playgrounds/${name}/reset`);
|
|
511
|
+
toast('원래 상태로 되돌렸습니다', 'success');
|
|
512
|
+
refreshChanges(name);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
toast(`되돌리기 실패: ${err.message}`, 'error');
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Snapshot modal
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
let snapModalName = null;
|
|
523
|
+
|
|
524
|
+
window.openSnapModal = async function openSnapModal(name) {
|
|
525
|
+
snapModalName = name;
|
|
526
|
+
document.getElementById('snap-modal-title').textContent = `Snapshots — ${name}`;
|
|
527
|
+
document.getElementById('snap-label-input').value = '';
|
|
528
|
+
document.getElementById('snap-modal').classList.add('open');
|
|
529
|
+
await loadSnapshots(name);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
window.closeSnapModal = function closeSnapModal() {
|
|
533
|
+
document.getElementById('snap-modal').classList.remove('open');
|
|
534
|
+
snapModalName = null;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
async function loadSnapshots(name) {
|
|
538
|
+
const list = document.getElementById('snap-list');
|
|
539
|
+
list.innerHTML = '<div class="snap-empty">Loading…</div>';
|
|
540
|
+
try {
|
|
541
|
+
const snaps = await api('GET', `/api/playgrounds/${name}/snapshots`);
|
|
542
|
+
if (!snaps.length) {
|
|
543
|
+
list.innerHTML = '<div class="snap-empty">No snapshots yet.</div>';
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
list.innerHTML = snaps.map((snap) => {
|
|
547
|
+
// Clean up the message: remove "On (no branch): playground-snapshot:" prefix
|
|
548
|
+
const label = (snap.message || '')
|
|
549
|
+
.replace(/^On \([^)]*\): /, '')
|
|
550
|
+
.replace(/^playground-snapshot:/, '')
|
|
551
|
+
.trim() || `스냅샷 #${snap.index}`;
|
|
552
|
+
const date = snap.date ? `<span style="color:var(--text-muted);font-size:11px;margin-left:8px">${escHtml(snap.date)}</span>` : '';
|
|
553
|
+
return `
|
|
554
|
+
<div class="snap-item">
|
|
555
|
+
<span class="snap-label">${escHtml(label)}${date}</span>
|
|
556
|
+
<button class="btn btn-ghost btn-sm" onclick="restoreSnap(${snap.index})">Restore</button>
|
|
557
|
+
</div>`;
|
|
558
|
+
}).join('');
|
|
559
|
+
} catch (err) {
|
|
560
|
+
list.innerHTML = `<div class="snap-empty">Error: ${escHtml(err.message)}</div>`;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
window.saveSnap = async function saveSnap() {
|
|
565
|
+
if (!snapModalName) return;
|
|
566
|
+
const label = document.getElementById('snap-label-input').value.trim()
|
|
567
|
+
|| new Date().toISOString();
|
|
568
|
+
try {
|
|
569
|
+
await api('POST', `/api/playgrounds/${snapModalName}/snapshot`, { label });
|
|
570
|
+
toast('Snapshot saved', 'success');
|
|
571
|
+
await loadSnapshots(snapModalName);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
toast(`Save failed: ${err.message}`, 'error');
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
window.copyDebugInfo = async function copyDebugInfo(name) {
|
|
578
|
+
const pg = playgrounds.get(name);
|
|
579
|
+
if (!pg) return;
|
|
580
|
+
|
|
581
|
+
const logLines = (logs.get(name) || []).slice(-30);
|
|
582
|
+
const diagChecks = diagnostics.get(name) || [];
|
|
583
|
+
|
|
584
|
+
let diagText = '';
|
|
585
|
+
if (diagChecks.length > 0) {
|
|
586
|
+
diagText = diagChecks.map(c => ` [${c.status}] ${c.name}: ${c.detail}`).join('\n');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Fetch fresh diagnostics if none cached
|
|
590
|
+
if (diagChecks.length === 0) {
|
|
591
|
+
try {
|
|
592
|
+
const fresh = await api('GET', `/api/playgrounds/${name}/diagnostics`);
|
|
593
|
+
diagText = fresh.map(c => ` [${c.status}] ${c.name}: ${c.detail}`).join('\n');
|
|
594
|
+
} catch { /* ignore */ }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const info = [
|
|
598
|
+
`## 캠프 디버그 정보`,
|
|
599
|
+
`- Name: ${pg.name}`,
|
|
600
|
+
`- Branch: ${pg.branch}`,
|
|
601
|
+
`- Status: ${pg.status}`,
|
|
602
|
+
`- URL: ${pg.url || "(시작 전)"}`,
|
|
603
|
+
``,
|
|
604
|
+
`### Diagnostics`,
|
|
605
|
+
diagText || ' (none)',
|
|
606
|
+
``,
|
|
607
|
+
`### Recent Logs (last 30 lines)`,
|
|
608
|
+
'```',
|
|
609
|
+
logLines.map(l => `[${l.source}] ${l.text}`).join(''),
|
|
610
|
+
'```',
|
|
611
|
+
].join('\n');
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
await navigator.clipboard.writeText(info);
|
|
615
|
+
toast('Debug info copied — paste it to Claude', 'success');
|
|
616
|
+
} catch {
|
|
617
|
+
// Fallback for non-HTTPS
|
|
618
|
+
const ta = document.createElement('textarea');
|
|
619
|
+
ta.value = info;
|
|
620
|
+
document.body.appendChild(ta);
|
|
621
|
+
ta.select();
|
|
622
|
+
document.execCommand('copy');
|
|
623
|
+
ta.remove();
|
|
624
|
+
toast('Debug info copied — paste it to Claude', 'success');
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
window.restoreSnap = async function restoreSnap(index) {
|
|
629
|
+
if (!snapModalName) return;
|
|
630
|
+
if (!confirm(`Restore snapshot #${index}? Current state will be overwritten.`)) return;
|
|
631
|
+
try {
|
|
632
|
+
await api('POST', `/api/playgrounds/${snapModalName}/restore`, { index });
|
|
633
|
+
toast('Snapshot restored', 'success');
|
|
634
|
+
closeSnapModal();
|
|
635
|
+
} catch (err) {
|
|
636
|
+
toast(`Restore failed: ${err.message}`, 'error');
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Close snap modal on backdrop click
|
|
641
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
642
|
+
document.getElementById('snap-modal').addEventListener('click', (e) => {
|
|
643
|
+
if (e.target === e.currentTarget) closeSnapModal();
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// New 산장 modal
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
let allBranches = [];
|
|
652
|
+
let allBranchData = [];
|
|
653
|
+
|
|
654
|
+
const CATEGORY_LABELS = {
|
|
655
|
+
default: '기본',
|
|
656
|
+
feature: '기능 개발',
|
|
657
|
+
fix: '버그 수정',
|
|
658
|
+
other: '기타',
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
function renderBranchDropdown(filter = '') {
|
|
662
|
+
const dropdown = document.getElementById('branch-dropdown');
|
|
663
|
+
const q = filter.toLowerCase();
|
|
664
|
+
|
|
665
|
+
const filtered = q
|
|
666
|
+
? allBranchData.filter(b => b.name.toLowerCase().includes(q))
|
|
667
|
+
: allBranchData;
|
|
668
|
+
|
|
669
|
+
if (filtered.length === 0) {
|
|
670
|
+
dropdown.innerHTML = '<div class="branch-empty">검색 결과 없음</div>';
|
|
671
|
+
dropdown.classList.add('open');
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Group: show "최근" (top 8) when no filter, otherwise just filtered results
|
|
676
|
+
let html = '';
|
|
677
|
+
if (!q) {
|
|
678
|
+
// Recent top 8
|
|
679
|
+
const recent = filtered.slice(0, 8);
|
|
680
|
+
html += '<div class="branch-group-label">최근</div>';
|
|
681
|
+
for (const b of recent) {
|
|
682
|
+
html += branchItemHtml(b);
|
|
683
|
+
}
|
|
684
|
+
// Then by category (skip already shown)
|
|
685
|
+
const recentNames = new Set(recent.map(r => r.name));
|
|
686
|
+
const rest = filtered.filter(b => !recentNames.has(b.name));
|
|
687
|
+
const groups = {};
|
|
688
|
+
for (const b of rest) {
|
|
689
|
+
const cat = b.category || 'other';
|
|
690
|
+
if (!groups[cat]) groups[cat] = [];
|
|
691
|
+
groups[cat].push(b);
|
|
692
|
+
}
|
|
693
|
+
for (const cat of ['default', 'feature', 'fix', 'other']) {
|
|
694
|
+
if (!groups[cat] || groups[cat].length === 0) continue;
|
|
695
|
+
html += `<div class="branch-group-label">${CATEGORY_LABELS[cat]}</div>`;
|
|
696
|
+
for (const b of groups[cat]) {
|
|
697
|
+
html += branchItemHtml(b);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
for (const b of filtered) {
|
|
702
|
+
html += branchItemHtml(b);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
dropdown.innerHTML = html;
|
|
707
|
+
dropdown.classList.add('open');
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function branchItemHtml(b) {
|
|
711
|
+
const dateStr = b.date ? `<span class="branch-date">${escHtml(b.date)}</span>` : '';
|
|
712
|
+
const localTag = b.local && !b.remote ? '<span class="branch-tag">local</span>' : '';
|
|
713
|
+
return `<div class="branch-item" data-name="${escHtml(b.name)}">`
|
|
714
|
+
+ `<span class="branch-item-name">${escHtml(b.name)}</span>`
|
|
715
|
+
+ `<span class="branch-item-meta">${localTag}${dateStr}</span>`
|
|
716
|
+
+ `</div>`;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
window.openNewModal = async function openNewModal() {
|
|
720
|
+
document.getElementById('new-pg-name').value = '';
|
|
721
|
+
document.getElementById('new-pg-name-error').textContent = '';
|
|
722
|
+
const input = document.getElementById('new-pg-branch');
|
|
723
|
+
const dropdown = document.getElementById('branch-dropdown');
|
|
724
|
+
const countEl = document.getElementById('branch-count');
|
|
725
|
+
input.value = '';
|
|
726
|
+
dropdown.innerHTML = '';
|
|
727
|
+
dropdown.classList.remove('open');
|
|
728
|
+
countEl.textContent = '불러오는 중...';
|
|
729
|
+
document.getElementById('new-pg-modal').classList.add('open');
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const branchList = await api('GET', '/api/branches');
|
|
733
|
+
allBranchData = branchList;
|
|
734
|
+
allBranches = branchList.map(b => b.name || b);
|
|
735
|
+
countEl.textContent = `${allBranches.length}개 브랜치`;
|
|
736
|
+
renderBranchDropdown();
|
|
737
|
+
} catch (err) {
|
|
738
|
+
countEl.textContent = '브랜치를 불러올 수 없습니다';
|
|
739
|
+
toast(`브랜치 로드 실패: ${err.message}`, 'error');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Wire up search filtering
|
|
743
|
+
input.oninput = () => renderBranchDropdown(input.value);
|
|
744
|
+
input.onfocus = () => renderBranchDropdown(input.value);
|
|
745
|
+
|
|
746
|
+
// Wire up click selection on dropdown
|
|
747
|
+
dropdown.onclick = (e) => {
|
|
748
|
+
const item = e.target.closest('.branch-item');
|
|
749
|
+
if (!item) return;
|
|
750
|
+
input.value = item.dataset.name;
|
|
751
|
+
dropdown.classList.remove('open');
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// Close dropdown on outside click
|
|
755
|
+
setTimeout(() => {
|
|
756
|
+
const closer = (e) => {
|
|
757
|
+
if (!document.getElementById('branch-picker').contains(e.target)) {
|
|
758
|
+
dropdown.classList.remove('open');
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
document.getElementById('new-pg-modal').addEventListener('click', closer);
|
|
762
|
+
}, 0);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
window.closeNewModal = function closeNewModal() {
|
|
766
|
+
document.getElementById('new-pg-modal').classList.remove('open');
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
window.createPg = async function createPg() {
|
|
770
|
+
const name = document.getElementById('new-pg-name').value.trim();
|
|
771
|
+
const branch = document.getElementById('new-pg-branch').value;
|
|
772
|
+
const errEl = document.getElementById('new-pg-name-error');
|
|
773
|
+
|
|
774
|
+
errEl.textContent = '';
|
|
775
|
+
|
|
776
|
+
if (!name) {
|
|
777
|
+
errEl.textContent = 'Name is required.';
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
781
|
+
errEl.textContent = 'Only lowercase letters, numbers, and hyphens allowed.';
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (!branch) {
|
|
785
|
+
errEl.textContent = 'Please enter a branch name.';
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (allBranches.length > 0 && !allBranches.includes(branch)) {
|
|
789
|
+
errEl.textContent = `"${branch}" 브랜치를 찾을 수 없습니다. 목록에서 선택해주세요.`;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const btn = document.getElementById('create-pg-btn');
|
|
794
|
+
btn.disabled = true;
|
|
795
|
+
btn.textContent = '만드는 중...';
|
|
796
|
+
toast('캠프를 만들고 있습니다... (의존성 설치 중)', 'info');
|
|
797
|
+
try {
|
|
798
|
+
await api('POST', '/api/playgrounds', { name, branch });
|
|
799
|
+
closeNewModal();
|
|
800
|
+
} catch (err) {
|
|
801
|
+
toast(`Create failed: ${err.message}`, 'error');
|
|
802
|
+
errEl.textContent = err.message;
|
|
803
|
+
} finally {
|
|
804
|
+
btn.disabled = false;
|
|
805
|
+
btn.textContent = '생성';
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// Close new modal on backdrop click
|
|
810
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
811
|
+
document.getElementById('new-pg-modal').addEventListener('click', (e) => {
|
|
812
|
+
if (e.target === e.currentTarget) closeNewModal();
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// Enter key in name field submits
|
|
817
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
818
|
+
document.getElementById('new-pg-name').addEventListener('keydown', (e) => {
|
|
819
|
+
if (e.key === 'Enter') createPg();
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// 변경 상태 — 카드에 "N개 파일 변경됨" 표시
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
async function refreshChanges(name) {
|
|
828
|
+
const el = document.getElementById(`changes-${name}`);
|
|
829
|
+
if (!el) return;
|
|
830
|
+
try {
|
|
831
|
+
const data = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
832
|
+
if (data.count === 0 && (!data.actions || data.actions.length === 0)) {
|
|
833
|
+
el.innerHTML = '';
|
|
834
|
+
} else {
|
|
835
|
+
const actionCount = data.actions?.length || 0;
|
|
836
|
+
const label = actionCount > 0
|
|
837
|
+
? `${actionCount}개 변경 작업`
|
|
838
|
+
: `${data.count}개 파일 변경됨`;
|
|
839
|
+
el.innerHTML = `<span class="changes-badge" title="수정된 파일 목록 보기 + 되돌리기" onclick="openChangesModal('${escHtml(name)}')">${label}</span>`;
|
|
840
|
+
}
|
|
841
|
+
} catch { el.innerHTML = ''; }
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Refresh changes for all running playgrounds periodically
|
|
845
|
+
setInterval(() => {
|
|
846
|
+
for (const [name, pg] of playgrounds) {
|
|
847
|
+
if (pg.status === 'running') refreshChanges(name);
|
|
848
|
+
}
|
|
849
|
+
}, 10000);
|
|
850
|
+
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// 변경 내역 모달 — 파일 목록 + 선택 되돌리기
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
let changesModalName = null;
|
|
856
|
+
|
|
857
|
+
window.openChangesModal = async function openChangesModal(name) {
|
|
858
|
+
changesModalName = name;
|
|
859
|
+
const modal = document.getElementById('changes-modal');
|
|
860
|
+
document.getElementById('changes-modal-title').textContent = `변경 내역 — ${name}`;
|
|
861
|
+
const list = document.getElementById('changes-file-list');
|
|
862
|
+
list.innerHTML = '<div style="color:var(--text-muted);padding:8px">로딩 중...</div>';
|
|
863
|
+
modal.classList.add('open');
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const data = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
867
|
+
if (data.count === 0 && (!data.actions || data.actions.length === 0)) {
|
|
868
|
+
list.innerHTML = '<div style="color:var(--text-muted);padding:8px">변경 사항이 없습니다.</div>';
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let html = '';
|
|
873
|
+
|
|
874
|
+
// 행위 로그 + 행위별 되돌리기
|
|
875
|
+
if (data.actions && data.actions.length > 0) {
|
|
876
|
+
html += '<div class="changes-section-title">변경 작업</div>';
|
|
877
|
+
html += data.actions.map((a, i) => `
|
|
878
|
+
<div class="changes-action-item">
|
|
879
|
+
<span class="changes-action-dot"></span>
|
|
880
|
+
<div class="changes-action-body">
|
|
881
|
+
<span class="changes-action-desc">${escHtml(a.description)}</span>
|
|
882
|
+
<span class="changes-action-time">${new Date(a.timestamp).toLocaleTimeString('ko-KR', {hour:'2-digit',minute:'2-digit'})}</span>
|
|
883
|
+
</div>
|
|
884
|
+
${a.files && a.files.length > 0
|
|
885
|
+
? `<button class="btn btn-ghost btn-sm" onclick="revertAction(${i})">되돌리기</button>`
|
|
886
|
+
: ''}
|
|
887
|
+
</div>
|
|
888
|
+
`).join('');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// 파일 목록 (접이식, 개별 되돌리기용)
|
|
892
|
+
if (data.count > 0) {
|
|
893
|
+
html += `
|
|
894
|
+
<details class="changes-files-detail">
|
|
895
|
+
<summary class="changes-section-title" style="cursor:pointer">파일 상세 (${data.count}개)</summary>
|
|
896
|
+
${data.files.map(f => `
|
|
897
|
+
<div class="changes-file-item">
|
|
898
|
+
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
899
|
+
<span class="changes-path">${escHtml(f.path.replace('new-frontend/', ''))}</span>
|
|
900
|
+
<button class="btn btn-ghost btn-sm" onclick="revertFiles(['${escHtml(f.path)}'])">되돌리기</button>
|
|
901
|
+
</div>
|
|
902
|
+
`).join('')}
|
|
903
|
+
</details>`;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
list.innerHTML = html;
|
|
907
|
+
} catch (err) {
|
|
908
|
+
list.innerHTML = `<div style="color:var(--status-error-fg);padding:8px">${escHtml(err.message)}</div>`;
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
window.closeChangesModal = function() {
|
|
913
|
+
document.getElementById('changes-modal').classList.remove('open');
|
|
914
|
+
changesModalName = null;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
// 행위 단위 되돌리기
|
|
918
|
+
window.revertAction = async function revertAction(actionIndex) {
|
|
919
|
+
if (!changesModalName) return;
|
|
920
|
+
try {
|
|
921
|
+
const data = await api('GET', `/api/playgrounds/${changesModalName}/changes`);
|
|
922
|
+
const action = data.actions?.[actionIndex];
|
|
923
|
+
if (!action || !action.files?.length) {
|
|
924
|
+
toast('되돌릴 파일 정보가 없습니다.', 'error');
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (!confirm(`"${action.description}"을(를) 되돌릴까요?`)) return;
|
|
928
|
+
|
|
929
|
+
await api('POST', `/api/playgrounds/${changesModalName}/revert-files`, { files: action.files });
|
|
930
|
+
// Remove action from log
|
|
931
|
+
await api('POST', `/api/playgrounds/${changesModalName}/remove-action`, { index: actionIndex });
|
|
932
|
+
toast(`"${action.description}" 되돌림 완료`, 'success');
|
|
933
|
+
openChangesModal(changesModalName);
|
|
934
|
+
refreshChanges(changesModalName);
|
|
935
|
+
} catch (err) {
|
|
936
|
+
toast(`되돌리기 실패: ${err.message}`, 'error');
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// 파일 단위 되돌리기
|
|
941
|
+
window.revertFiles = async function revertFiles(files) {
|
|
942
|
+
if (!changesModalName) return;
|
|
943
|
+
if (!confirm(`선택한 파일을 원래대로 되돌릴까요?`)) return;
|
|
944
|
+
try {
|
|
945
|
+
await api('POST', `/api/playgrounds/${changesModalName}/revert-files`, { files });
|
|
946
|
+
toast('되돌림 완료', 'success');
|
|
947
|
+
openChangesModal(changesModalName);
|
|
948
|
+
refreshChanges(changesModalName);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
toast(`되돌리기 실패: ${err.message}`, 'error');
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// 보내기 모달 — 커밋 + PR
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
let shipModalName = null;
|
|
959
|
+
|
|
960
|
+
window.openShipModal = async function openShipModal(name) {
|
|
961
|
+
shipModalName = name;
|
|
962
|
+
document.getElementById('ship-modal').classList.add('open');
|
|
963
|
+
document.getElementById('ship-message').value = '';
|
|
964
|
+
document.getElementById('ship-message').focus();
|
|
965
|
+
|
|
966
|
+
// Auto-generate description
|
|
967
|
+
api('POST', `/api/playgrounds/${name}/smart-pr`).then(data => {
|
|
968
|
+
if (data.description) {
|
|
969
|
+
document.getElementById('ship-message').value = data.description;
|
|
970
|
+
}
|
|
971
|
+
}).catch(() => {}); // silent fail
|
|
972
|
+
|
|
973
|
+
// Show changed file count
|
|
974
|
+
try {
|
|
975
|
+
const data = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
976
|
+
document.getElementById('ship-file-count').textContent =
|
|
977
|
+
data.count > 0 ? `${data.count}개 파일이 변경되었습니다.` : '변경된 파일이 없습니다.';
|
|
978
|
+
} catch {
|
|
979
|
+
document.getElementById('ship-file-count').textContent = '';
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
window.closeShipModal = function() {
|
|
984
|
+
document.getElementById('ship-modal').classList.remove('open');
|
|
985
|
+
shipModalName = null;
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
window.closeShipResultModal = function() {
|
|
989
|
+
document.getElementById('ship-result-modal').classList.remove('open');
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
function setShipStep(step, state) {
|
|
993
|
+
const el = document.getElementById(`ship-step-${step}`);
|
|
994
|
+
if (!el) return;
|
|
995
|
+
el.classList.remove('ship-step-active', 'ship-step-done', 'ship-step-fail');
|
|
996
|
+
if (state) el.classList.add(`ship-step-${state}`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
window.shipPg = async function shipPg() {
|
|
1000
|
+
if (!shipModalName) return;
|
|
1001
|
+
const message = document.getElementById('ship-message').value.trim();
|
|
1002
|
+
if (!message) { toast('변경 내용을 한 줄로 설명해주세요.', 'error'); return; }
|
|
1003
|
+
|
|
1004
|
+
const btn = document.getElementById('ship-btn');
|
|
1005
|
+
btn.disabled = true;
|
|
1006
|
+
btn.textContent = '진행 중...';
|
|
1007
|
+
|
|
1008
|
+
// Show steps
|
|
1009
|
+
const stepsEl = document.getElementById('ship-steps');
|
|
1010
|
+
stepsEl.classList.remove('hidden');
|
|
1011
|
+
setShipStep('test', 'active');
|
|
1012
|
+
setShipStep('push', null);
|
|
1013
|
+
setShipStep('pr', null);
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
// Step 1: Pre-ship test
|
|
1017
|
+
const testResult = await api('POST', `/api/playgrounds/${shipModalName}/pre-ship`);
|
|
1018
|
+
if (!testResult.passed && !testResult.skipped) {
|
|
1019
|
+
setShipStep('test', 'fail');
|
|
1020
|
+
btn.textContent = '보내기';
|
|
1021
|
+
btn.disabled = false;
|
|
1022
|
+
toast('테스트 실패 — 터미널에서 확인해주세요', 'error');
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
setShipStep('test', 'done');
|
|
1026
|
+
|
|
1027
|
+
// Step 2: Ship (squash + push)
|
|
1028
|
+
setShipStep('push', 'active');
|
|
1029
|
+
await api('POST', `/api/playgrounds/${shipModalName}/ship`, { message });
|
|
1030
|
+
setShipStep('push', 'done');
|
|
1031
|
+
|
|
1032
|
+
// Step 3: PR (happens in background via WebSocket)
|
|
1033
|
+
setShipStep('pr', 'active');
|
|
1034
|
+
closeShipModal();
|
|
1035
|
+
|
|
1036
|
+
const content = document.getElementById('ship-result-content');
|
|
1037
|
+
content.innerHTML = `
|
|
1038
|
+
<p style="margin-bottom:12px">코드가 올라갔습니다!</p>
|
|
1039
|
+
<p style="font-size:13px;color:var(--text-muted)">
|
|
1040
|
+
PR을 만드는 중입니다... 잠시 후 알림이 옵니다.
|
|
1041
|
+
</p>`;
|
|
1042
|
+
document.getElementById('ship-result-modal').classList.add('open');
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
toast(`보내기 실패: ${err.message}`, 'error');
|
|
1045
|
+
} finally {
|
|
1046
|
+
btn.disabled = false;
|
|
1047
|
+
btn.textContent = '보내기';
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
// 최신 반영 (sync) + 충돌 해결
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
let conflictCampName = null;
|
|
1056
|
+
|
|
1057
|
+
window.syncPg = async function syncPg(name) {
|
|
1058
|
+
if (!confirm('팀의 최신 변경사항을 가져올까요?')) return;
|
|
1059
|
+
try {
|
|
1060
|
+
const result = await api('POST', `/api/playgrounds/${name}/sync`);
|
|
1061
|
+
if (result.conflict) {
|
|
1062
|
+
conflictCampName = name;
|
|
1063
|
+
const fileList = document.getElementById('conflict-files');
|
|
1064
|
+
if (result.conflictFiles?.length) {
|
|
1065
|
+
fileList.innerHTML = `<div style="font-size:12px;background:var(--bg-card);padding:8px;border-radius:4px">
|
|
1066
|
+
충돌 파일: ${result.conflictFiles.map(f => `<code>${escHtml(f)}</code>`).join(', ')}
|
|
1067
|
+
</div>`;
|
|
1068
|
+
} else {
|
|
1069
|
+
fileList.innerHTML = '';
|
|
1070
|
+
}
|
|
1071
|
+
document.getElementById('conflict-modal').classList.add('open');
|
|
1072
|
+
} else {
|
|
1073
|
+
toast(result.message, 'success');
|
|
1074
|
+
}
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
toast(`최신 반영 실패: ${err.message}`, 'error');
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
window.resolveConflict = async function resolveConflict(strategy) {
|
|
1081
|
+
if (!conflictCampName) return;
|
|
1082
|
+
document.getElementById('conflict-modal').classList.remove('open');
|
|
1083
|
+
const label = { claude: 'Claude가 해결 중...', ours: '내 것으로 적용 중...', theirs: '팀 것으로 적용 중...' };
|
|
1084
|
+
toast(label[strategy] || '처리 중...', 'info');
|
|
1085
|
+
try {
|
|
1086
|
+
await api('POST', `/api/playgrounds/${conflictCampName}/resolve-conflict`, { strategy });
|
|
1087
|
+
if (strategy !== 'claude') {
|
|
1088
|
+
toast('충돌이 해결되었습니다!', 'success');
|
|
1089
|
+
}
|
|
1090
|
+
// claude strategy: result arrives via WebSocket conflict-resolved/conflict-failed
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
toast(`충돌 해결 실패: ${err.message}`, 'error');
|
|
1093
|
+
}
|
|
1094
|
+
conflictCampName = null;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
window.resolveAbort = async function resolveAbort() {
|
|
1098
|
+
if (!conflictCampName) return;
|
|
1099
|
+
document.getElementById('conflict-modal').classList.remove('open');
|
|
1100
|
+
try {
|
|
1101
|
+
await api('POST', `/api/playgrounds/${conflictCampName}/resolve-abort`);
|
|
1102
|
+
toast('원래대로 되돌렸습니다.', 'success');
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
toast(`되돌리기 실패: ${err.message}`, 'error');
|
|
1105
|
+
}
|
|
1106
|
+
conflictCampName = null;
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// Workspace View — SPA routing (list ↔ workspace)
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
function enterWorkspace(name) {
|
|
1115
|
+
currentWorkspace = name;
|
|
1116
|
+
clearBrowserErrors();
|
|
1117
|
+
document.getElementById('grid').classList.add('hidden');
|
|
1118
|
+
document.getElementById('portal').classList.add('hidden');
|
|
1119
|
+
document.querySelector('header').classList.add('hidden');
|
|
1120
|
+
const ws = document.getElementById('workspace');
|
|
1121
|
+
ws.classList.remove('hidden');
|
|
1122
|
+
|
|
1123
|
+
// Call enter API
|
|
1124
|
+
api('POST', `/api/playgrounds/${name}/enter`).then(data => {
|
|
1125
|
+
renderWorkspace(data);
|
|
1126
|
+
}).catch(err => {
|
|
1127
|
+
toast(`캠프 진입 실패: ${err.message}`, 'error');
|
|
1128
|
+
exitWorkspace();
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function exitWorkspace() {
|
|
1133
|
+
currentWorkspace = null;
|
|
1134
|
+
if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
|
|
1135
|
+
document.getElementById('workspace').classList.add('hidden');
|
|
1136
|
+
document.getElementById('grid').classList.remove('hidden');
|
|
1137
|
+
document.getElementById('portal').classList.remove('hidden');
|
|
1138
|
+
document.querySelector('header').classList.remove('hidden');
|
|
1139
|
+
renderAll();
|
|
1140
|
+
loadPortal();
|
|
1141
|
+
}
|
|
1142
|
+
window.exitWorkspace = exitWorkspace;
|
|
1143
|
+
|
|
1144
|
+
function renderWorkspace(data) {
|
|
1145
|
+
const { camp, changes, warpInstalled, previewUrl, autosave } = data;
|
|
1146
|
+
|
|
1147
|
+
// Header
|
|
1148
|
+
document.getElementById('ws-title').textContent = `캠프: ${camp.name}`;
|
|
1149
|
+
const statusEl = document.getElementById('ws-status');
|
|
1150
|
+
statusEl.textContent = camp.status;
|
|
1151
|
+
statusEl.className = `workspace-status badge badge-${camp.status}`;
|
|
1152
|
+
|
|
1153
|
+
// Mini character in topbar — sherpa load level
|
|
1154
|
+
updateMiniChar(camp.status, changes.count);
|
|
1155
|
+
|
|
1156
|
+
// Changes — unsaved indicator + save button
|
|
1157
|
+
const changesEl = document.getElementById('ws-changes');
|
|
1158
|
+
const summaryTextEl = document.getElementById('ws-changes-summary-text');
|
|
1159
|
+
const unsavedSection = document.getElementById('ws-unsaved-section');
|
|
1160
|
+
const saveBtn = document.getElementById('ws-save-btn');
|
|
1161
|
+
if (changes.count === 0) {
|
|
1162
|
+
unsavedSection.classList.add('ws-no-changes');
|
|
1163
|
+
summaryTextEl.textContent = '✅ 모든 변경이 세이브됨';
|
|
1164
|
+
saveBtn.style.display = 'none';
|
|
1165
|
+
changesEl.innerHTML = '';
|
|
1166
|
+
renderBlocks([]);
|
|
1167
|
+
} else {
|
|
1168
|
+
unsavedSection.classList.remove('ws-no-changes');
|
|
1169
|
+
summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
|
|
1170
|
+
saveBtn.style.display = '';
|
|
1171
|
+
saveBtn.textContent = '💾 세이브하기';
|
|
1172
|
+
saveBtn.disabled = false;
|
|
1173
|
+
changesEl.innerHTML = changes.files.map(f =>
|
|
1174
|
+
`<div class="ws-file-item">
|
|
1175
|
+
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
1176
|
+
<span>${escHtml(f.path)}</span>
|
|
1177
|
+
</div>`
|
|
1178
|
+
).join('');
|
|
1179
|
+
renderBlocks(changes.files);
|
|
1180
|
+
// Fetch AI summary
|
|
1181
|
+
api('GET', `/api/playgrounds/${camp.name}/changes-summary`).then(data => {
|
|
1182
|
+
if (data.summary) summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${data.summary}`;
|
|
1183
|
+
}).catch(() => {});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Actions — show commits as work history
|
|
1187
|
+
const actionsEl = document.getElementById('ws-actions');
|
|
1188
|
+
const commitList = data.commits || [];
|
|
1189
|
+
if (commitList.length > 0) {
|
|
1190
|
+
actionsEl.innerHTML = commitList.map(c =>
|
|
1191
|
+
`<div class="ws-commit-item">
|
|
1192
|
+
<span class="ws-commit-msg">${escHtml(c.message)}</span>
|
|
1193
|
+
<span class="ws-commit-date">${escHtml(c.date)}</span>
|
|
1194
|
+
<button class="btn btn-ghost btn-sm ws-revert-btn" onclick="revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
|
|
1195
|
+
</div>`
|
|
1196
|
+
).join('');
|
|
1197
|
+
} else if (changes.count > 0) {
|
|
1198
|
+
actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
|
|
1199
|
+
} else {
|
|
1200
|
+
actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 없음</span>';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Preview — use proxy URL (same origin, no X-Frame-Options issues)
|
|
1204
|
+
const previewEl = document.getElementById('ws-preview');
|
|
1205
|
+
if (previewUrl) {
|
|
1206
|
+
const port = new URL(previewUrl).port || '80';
|
|
1207
|
+
const proxyUrl = `/preview/${port}/`;
|
|
1208
|
+
previewEl.innerHTML = `
|
|
1209
|
+
<iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>
|
|
1210
|
+
<div class="ws-preview-fallback" style="display:none">
|
|
1211
|
+
<a href="${escHtml(previewUrl)}" target="_blank" class="btn btn-primary">
|
|
1212
|
+
새 탭에서 열기 → ${escHtml(previewUrl)}
|
|
1213
|
+
</a>
|
|
1214
|
+
</div>`;
|
|
1215
|
+
const iframe = previewEl.querySelector('iframe');
|
|
1216
|
+
iframe.addEventListener('error', () => {
|
|
1217
|
+
iframe.style.display = 'none';
|
|
1218
|
+
previewEl.querySelector('.ws-preview-fallback').style.display = 'flex';
|
|
1219
|
+
});
|
|
1220
|
+
} else {
|
|
1221
|
+
previewEl.innerHTML = `<span style="color:var(--text-muted);font-size:13px">
|
|
1222
|
+
서버가 실행 중이 아닙니다. 먼저 시작해주세요.
|
|
1223
|
+
</span>`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Terminal button label
|
|
1227
|
+
const termBtn = document.getElementById('ws-terminal-btn');
|
|
1228
|
+
termBtn.textContent = warpInstalled ? '💻 터미널' : '💻 경로 복사';
|
|
1229
|
+
|
|
1230
|
+
// Autosave toggle
|
|
1231
|
+
const autosaveCheck = document.getElementById('ws-autosave-check');
|
|
1232
|
+
if (autosaveCheck) autosaveCheck.checked = !!autosave;
|
|
1233
|
+
|
|
1234
|
+
// Log — show existing logs
|
|
1235
|
+
updateWorkspaceLog(camp.name);
|
|
1236
|
+
|
|
1237
|
+
// Quest progress bar
|
|
1238
|
+
const hasChanges = changes.count > 0;
|
|
1239
|
+
const hasSaves = (data.commits || []).length > 0;
|
|
1240
|
+
updateQuestProgress(hasChanges, hasSaves);
|
|
1241
|
+
|
|
1242
|
+
// Start polling changes
|
|
1243
|
+
startWorkspacePolling(camp.name);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function updateChangeSummary(count, ts) {
|
|
1247
|
+
let summary = document.getElementById('ws-changes-summary');
|
|
1248
|
+
if (!summary) {
|
|
1249
|
+
const changesSection = document.getElementById('ws-changes')?.parentElement;
|
|
1250
|
+
if (!changesSection) return;
|
|
1251
|
+
const h3 = changesSection.querySelector('h3');
|
|
1252
|
+
if (!h3) return;
|
|
1253
|
+
summary = document.createElement('span');
|
|
1254
|
+
summary.id = 'ws-changes-summary';
|
|
1255
|
+
summary.className = 'ws-changes-summary';
|
|
1256
|
+
h3.appendChild(summary);
|
|
1257
|
+
}
|
|
1258
|
+
if (count === 0) {
|
|
1259
|
+
summary.textContent = '';
|
|
1260
|
+
} else {
|
|
1261
|
+
const ago = ts ? timeAgo(ts) : '';
|
|
1262
|
+
summary.textContent = ` · ${count}개 파일${ago ? ' · ' + ago : ''}`;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function timeAgo(ts) {
|
|
1267
|
+
const sec = Math.floor((Date.now() - ts) / 1000);
|
|
1268
|
+
if (sec < 5) return '방금';
|
|
1269
|
+
if (sec < 60) return `${sec}초 전`;
|
|
1270
|
+
return `${Math.floor(sec / 60)}분 전`;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function renderBlocks(files) {
|
|
1274
|
+
const container = document.getElementById('ws-blocks');
|
|
1275
|
+
if (!container) return;
|
|
1276
|
+
if (!files || files.length === 0) {
|
|
1277
|
+
container.innerHTML = '';
|
|
1278
|
+
container.classList.remove('ws-blocks-wobble');
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
container.innerHTML = files.map(f => {
|
|
1282
|
+
const type = f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del';
|
|
1283
|
+
return `<div class="ws-block ws-block-${type}" title="${f.path}"></div>`;
|
|
1284
|
+
}).join('');
|
|
1285
|
+
container.classList.toggle('ws-blocks-wobble', files.length >= 5);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function playSaveEffect() {
|
|
1289
|
+
// 1. Flush blocks
|
|
1290
|
+
const blocks = document.getElementById('ws-blocks');
|
|
1291
|
+
if (blocks) {
|
|
1292
|
+
blocks.classList.add('ws-blocks-flush');
|
|
1293
|
+
setTimeout(() => {
|
|
1294
|
+
blocks.innerHTML = '';
|
|
1295
|
+
blocks.classList.remove('ws-blocks-flush', 'ws-blocks-wobble');
|
|
1296
|
+
}, 400);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// 2. Screen flash (retro single-color)
|
|
1300
|
+
const flash = document.createElement('div');
|
|
1301
|
+
flash.className = 'ws-save-flash';
|
|
1302
|
+
document.body.appendChild(flash);
|
|
1303
|
+
setTimeout(() => flash.remove(), 400);
|
|
1304
|
+
|
|
1305
|
+
// 3. Pixel sparkles around save button
|
|
1306
|
+
const btn = document.getElementById('ws-save-btn');
|
|
1307
|
+
if (btn) {
|
|
1308
|
+
btn.style.position = 'relative';
|
|
1309
|
+
const sparkles = document.createElement('div');
|
|
1310
|
+
sparkles.className = 'ws-sparkles';
|
|
1311
|
+
for (let i = 0; i < 10; i++) {
|
|
1312
|
+
const s = document.createElement('div');
|
|
1313
|
+
s.className = 'ws-sparkle';
|
|
1314
|
+
const angle = (i / 10) * Math.PI * 2;
|
|
1315
|
+
const dist = 24 + Math.random() * 16;
|
|
1316
|
+
s.style.setProperty('--sx', `${Math.cos(angle) * dist}px`);
|
|
1317
|
+
s.style.setProperty('--sy', `${Math.sin(angle) * dist}px`);
|
|
1318
|
+
s.style.left = '50%';
|
|
1319
|
+
s.style.top = '50%';
|
|
1320
|
+
sparkles.appendChild(s);
|
|
1321
|
+
}
|
|
1322
|
+
btn.appendChild(sparkles);
|
|
1323
|
+
setTimeout(() => sparkles.remove(), 600);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// 4. Sherpa celebrates — jump + wave
|
|
1327
|
+
const miniChar = document.getElementById('ws-mini-char');
|
|
1328
|
+
if (miniChar) {
|
|
1329
|
+
miniChar.className = 'ws-mini-char ws-mini-char-saved';
|
|
1330
|
+
setTimeout(() => updateMiniChar('running', 0), 1000);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// 5. Slide-in new save entry in history
|
|
1334
|
+
setTimeout(() => {
|
|
1335
|
+
const actionsEl = document.getElementById('ws-actions');
|
|
1336
|
+
if (actionsEl) {
|
|
1337
|
+
const firstItem = actionsEl.querySelector('.ws-commit-item');
|
|
1338
|
+
if (firstItem) {
|
|
1339
|
+
firstItem.classList.add('ws-commit-slide-in');
|
|
1340
|
+
setTimeout(() => firstItem.classList.remove('ws-commit-slide-in'), 500);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}, 500); // Wait for workspace data refresh
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function updateMiniChar(status, changeCount) {
|
|
1347
|
+
const miniChar = document.getElementById('ws-mini-char');
|
|
1348
|
+
if (!miniChar) return;
|
|
1349
|
+
miniChar.className = 'ws-mini-char';
|
|
1350
|
+
|
|
1351
|
+
if (status === 'error') {
|
|
1352
|
+
miniChar.classList.add('ws-mini-char-error');
|
|
1353
|
+
} else if (status === 'starting' || status === 'starting-frontend') {
|
|
1354
|
+
miniChar.classList.add('ws-mini-char-starting');
|
|
1355
|
+
} else if (status === 'stopped') {
|
|
1356
|
+
miniChar.classList.add('ws-mini-char-stopped');
|
|
1357
|
+
} else if (changeCount >= 15) {
|
|
1358
|
+
miniChar.classList.add('ws-mini-char-load-heavy');
|
|
1359
|
+
} else if (changeCount >= 7) {
|
|
1360
|
+
miniChar.classList.add('ws-mini-char-load-medium');
|
|
1361
|
+
} else if (changeCount >= 3) {
|
|
1362
|
+
miniChar.classList.add('ws-mini-char-load-light');
|
|
1363
|
+
} else {
|
|
1364
|
+
miniChar.classList.add('ws-mini-char-running');
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function updateQuestProgress(hasChanges, hasSaves) {
|
|
1369
|
+
const stepWork = document.getElementById('ws-step-work');
|
|
1370
|
+
const stepSave = document.getElementById('ws-step-save');
|
|
1371
|
+
const stepShip = document.getElementById('ws-step-ship');
|
|
1372
|
+
if (!stepWork) return;
|
|
1373
|
+
|
|
1374
|
+
[stepWork, stepSave, stepShip].forEach(s => {
|
|
1375
|
+
s.classList.remove('ws-quest-active', 'ws-quest-done');
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
if (!hasChanges && !hasSaves) {
|
|
1379
|
+
stepWork.classList.add('ws-quest-active');
|
|
1380
|
+
} else if (hasChanges && !hasSaves) {
|
|
1381
|
+
stepWork.classList.add('ws-quest-done');
|
|
1382
|
+
stepSave.classList.add('ws-quest-active');
|
|
1383
|
+
} else if (!hasChanges && hasSaves) {
|
|
1384
|
+
stepWork.classList.add('ws-quest-done');
|
|
1385
|
+
stepSave.classList.add('ws-quest-done');
|
|
1386
|
+
stepShip.classList.add('ws-quest-active');
|
|
1387
|
+
} else {
|
|
1388
|
+
stepWork.classList.add('ws-quest-done');
|
|
1389
|
+
stepSave.classList.add('ws-quest-active');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
let summaryFetchTimer = null;
|
|
1394
|
+
function debounceSummaryFetch(campName) {
|
|
1395
|
+
if (summaryFetchTimer) clearTimeout(summaryFetchTimer);
|
|
1396
|
+
summaryFetchTimer = setTimeout(() => {
|
|
1397
|
+
api('GET', `/api/playgrounds/${campName}/changes-summary`).then(data => {
|
|
1398
|
+
const el = document.getElementById('ws-changes-summary-text');
|
|
1399
|
+
if (data.summary && el) el.textContent = data.summary;
|
|
1400
|
+
}).catch(() => {});
|
|
1401
|
+
}, 3000);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
let previewRefreshTimer = null;
|
|
1405
|
+
function debouncePreviewRefresh() {
|
|
1406
|
+
if (previewRefreshTimer) clearTimeout(previewRefreshTimer);
|
|
1407
|
+
previewRefreshTimer = setTimeout(() => {
|
|
1408
|
+
const iframe = document.querySelector('#ws-preview iframe');
|
|
1409
|
+
if (iframe) {
|
|
1410
|
+
try { iframe.contentWindow.location.reload(); } catch {
|
|
1411
|
+
iframe.src = iframe.src;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}, 1000);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function startWorkspacePolling(name) {
|
|
1418
|
+
if (wsPollingInterval) clearInterval(wsPollingInterval);
|
|
1419
|
+
wsPollingInterval = setInterval(async () => {
|
|
1420
|
+
if (currentWorkspace !== name) {
|
|
1421
|
+
clearInterval(wsPollingInterval);
|
|
1422
|
+
wsPollingInterval = null;
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
const data = await api('GET', `/api/playgrounds/${name}/changes`);
|
|
1427
|
+
const changesEl = document.getElementById('ws-changes');
|
|
1428
|
+
if (!changesEl) return;
|
|
1429
|
+
if (data.count === 0) {
|
|
1430
|
+
changesEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">변경 없음</span>';
|
|
1431
|
+
} else {
|
|
1432
|
+
changesEl.innerHTML = data.files.map(f =>
|
|
1433
|
+
`<div class="ws-file-item">
|
|
1434
|
+
<span class="changes-status changes-status-${f.status === '수정' ? 'mod' : f.status === '새 파일' ? 'new' : 'del'}">${escHtml(f.status)}</span>
|
|
1435
|
+
<span>${escHtml(f.path)}</span>
|
|
1436
|
+
</div>`
|
|
1437
|
+
).join('');
|
|
1438
|
+
}
|
|
1439
|
+
// Update actions
|
|
1440
|
+
const actionsEl = document.getElementById('ws-actions');
|
|
1441
|
+
if (actionsEl && data.actions?.length) {
|
|
1442
|
+
actionsEl.innerHTML = data.actions.map(a =>
|
|
1443
|
+
`<div class="ws-action-item">• ${escHtml(a.description)}</div>`
|
|
1444
|
+
).join('');
|
|
1445
|
+
}
|
|
1446
|
+
} catch { /* ignore */ }
|
|
1447
|
+
}, 10000);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function updateWorkspaceLog(name) {
|
|
1451
|
+
const panel = document.getElementById('ws-log');
|
|
1452
|
+
if (!panel) return;
|
|
1453
|
+
const pre = panel.querySelector('pre');
|
|
1454
|
+
if (!pre) return;
|
|
1455
|
+
const lines = logs.get(name) ?? [];
|
|
1456
|
+
pre.innerHTML = lines.map(({ text, source }) => {
|
|
1457
|
+
const cls = source === 'frontend' ? 'log-line-fe'
|
|
1458
|
+
: source === 'task' ? 'log-line-task'
|
|
1459
|
+
: 'log-line-err';
|
|
1460
|
+
return `<span class="${cls}">${escHtml(text)}</span>`;
|
|
1461
|
+
}).join('\n');
|
|
1462
|
+
panel.scrollTop = panel.scrollHeight;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// ---------------------------------------------------------------------------
|
|
1466
|
+
// Browser Error Panel
|
|
1467
|
+
// ---------------------------------------------------------------------------
|
|
1468
|
+
|
|
1469
|
+
/** @type {Array<{level: string, message: string, source?: string, line?: number, ts: number}>} */
|
|
1470
|
+
const browserErrors = [];
|
|
1471
|
+
|
|
1472
|
+
function addBrowserError(data) {
|
|
1473
|
+
browserErrors.push({ ...data, ts: Date.now() });
|
|
1474
|
+
if (browserErrors.length > 50) browserErrors.shift();
|
|
1475
|
+
renderBrowserErrors();
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function renderBrowserErrors() {
|
|
1479
|
+
const panel = document.getElementById('ws-browser-errors');
|
|
1480
|
+
if (!panel) return;
|
|
1481
|
+
const badge = document.getElementById('ws-browser-error-badge');
|
|
1482
|
+
if (browserErrors.length === 0) {
|
|
1483
|
+
panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
|
|
1484
|
+
if (badge) badge.style.display = 'none';
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (badge) {
|
|
1488
|
+
badge.style.display = '';
|
|
1489
|
+
badge.textContent = browserErrors.length;
|
|
1490
|
+
}
|
|
1491
|
+
panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
|
|
1492
|
+
const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
|
|
1493
|
+
return `<div class="ws-browser-error-item">
|
|
1494
|
+
<span class="ws-browser-error-level">${escHtml(e.level)}</span>
|
|
1495
|
+
<span class="ws-browser-error-msg">${escHtml(e.message)}</span>${loc}
|
|
1496
|
+
</div>`;
|
|
1497
|
+
}).join('');
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
window.toggleAutosave = async function toggleAutosave(enabled) {
|
|
1501
|
+
if (!currentWorkspace) return;
|
|
1502
|
+
try {
|
|
1503
|
+
await api('POST', `/api/playgrounds/${currentWorkspace}/autosave`, { enabled });
|
|
1504
|
+
toast(enabled ? '오토세이브 켜짐 (5분)' : '오토세이브 꺼짐', 'info');
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
toast(`오토세이브 설정 실패: ${err.message}`, 'error');
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
window.revertCommit = async function revertCommit(hash) {
|
|
1511
|
+
if (!currentWorkspace) return;
|
|
1512
|
+
if (!confirm('이 세이브를 되돌릴까요?')) return;
|
|
1513
|
+
try {
|
|
1514
|
+
await api('POST', `/api/playgrounds/${currentWorkspace}/revert-commit`, { hash });
|
|
1515
|
+
toast('되돌리기 완료', 'success');
|
|
1516
|
+
const data = await api('POST', `/api/playgrounds/${currentWorkspace}/enter`);
|
|
1517
|
+
renderWorkspace(data);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
toast(`되돌리기 실패: ${err.message}`, 'error');
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
function clearBrowserErrors() {
|
|
1524
|
+
browserErrors.length = 0;
|
|
1525
|
+
renderBrowserErrors();
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
window.wsShip = function() {
|
|
1529
|
+
if (!currentWorkspace) return;
|
|
1530
|
+
openShipModal(currentWorkspace);
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
window.wsSnap = function() {
|
|
1534
|
+
if (!currentWorkspace) return;
|
|
1535
|
+
openSnapModal(currentWorkspace);
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
window.wsSync = function() {
|
|
1539
|
+
if (!currentWorkspace) return;
|
|
1540
|
+
syncPg(currentWorkspace);
|
|
1541
|
+
};
|
|
1542
|
+
|
|
1543
|
+
window.wsReset = function() {
|
|
1544
|
+
if (!currentWorkspace) return;
|
|
1545
|
+
resetPg(currentWorkspace);
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
window.wsOpenTerminal = async function() {
|
|
1549
|
+
if (!currentWorkspace) return;
|
|
1550
|
+
const termBtn = document.getElementById('ws-terminal-btn');
|
|
1551
|
+
try {
|
|
1552
|
+
const result = await api('POST', `/api/playgrounds/${currentWorkspace}/open-terminal`);
|
|
1553
|
+
if (result.opened) {
|
|
1554
|
+
termBtn.textContent = '💻 열림 ✓';
|
|
1555
|
+
setTimeout(() => { termBtn.textContent = '💻 터미널 열기'; }, 2000);
|
|
1556
|
+
} else {
|
|
1557
|
+
// Fallback: copy path
|
|
1558
|
+
const path = result.path;
|
|
1559
|
+
await navigator.clipboard.writeText(`cd ${path}`).catch(() => {
|
|
1560
|
+
const ta = document.createElement('textarea');
|
|
1561
|
+
ta.value = `cd ${path}`;
|
|
1562
|
+
document.body.appendChild(ta);
|
|
1563
|
+
ta.select();
|
|
1564
|
+
document.execCommand('copy');
|
|
1565
|
+
ta.remove();
|
|
1566
|
+
});
|
|
1567
|
+
toast('경로가 복사되었습니다. 터미널에 붙여넣기 하세요.', 'success');
|
|
1568
|
+
}
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
toast(`터미널 열기 실패: ${err.message}`, 'error');
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
window.wsDelete = function() {
|
|
1575
|
+
if (!currentWorkspace) return;
|
|
1576
|
+
deletePg(currentWorkspace).then(() => exitWorkspace());
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
window.wsSave = async function() {
|
|
1580
|
+
if (!currentWorkspace) return;
|
|
1581
|
+
const btn = document.getElementById('ws-save-btn');
|
|
1582
|
+
btn.disabled = true;
|
|
1583
|
+
btn.textContent = '💾 세이브 중...';
|
|
1584
|
+
try {
|
|
1585
|
+
const result = await api('POST', `/api/playgrounds/${currentWorkspace}/save`);
|
|
1586
|
+
if (result.saved) {
|
|
1587
|
+
playSaveEffect();
|
|
1588
|
+
btn.textContent = '✅ 세이브 완료!';
|
|
1589
|
+
toast(`세이브됨: ${result.message}`, 'success');
|
|
1590
|
+
// Refresh workspace data
|
|
1591
|
+
const data = await api('POST', `/api/playgrounds/${currentWorkspace}/enter`);
|
|
1592
|
+
renderWorkspace(data);
|
|
1593
|
+
} else {
|
|
1594
|
+
btn.textContent = '💾 세이브하기';
|
|
1595
|
+
toast(result.reason || '변경사항이 없습니다.', 'info');
|
|
1596
|
+
}
|
|
1597
|
+
} catch (err) {
|
|
1598
|
+
btn.textContent = '💾 세이브하기';
|
|
1599
|
+
toast(`세이브 실패: ${err.message}`, 'error');
|
|
1600
|
+
}
|
|
1601
|
+
btn.disabled = false;
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
window.togglePanel = function() {
|
|
1605
|
+
document.getElementById('ws-panel')?.classList.toggle('open');
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// ---------------------------------------------------------------------------
|
|
1609
|
+
// Init
|
|
1610
|
+
// ---------------------------------------------------------------------------
|
|
1611
|
+
|
|
1612
|
+
// ---------------------------------------------------------------------------
|
|
1613
|
+
// Portal
|
|
1614
|
+
// ---------------------------------------------------------------------------
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
async function loadSuggestions() {
|
|
1618
|
+
const section = document.getElementById('portal-suggestions-section');
|
|
1619
|
+
const list = document.getElementById('portal-suggestions');
|
|
1620
|
+
if (!section || !list) return;
|
|
1621
|
+
|
|
1622
|
+
try {
|
|
1623
|
+
const suggestions = await api('GET', '/api/suggestions');
|
|
1624
|
+
if (!suggestions || suggestions.length === 0) {
|
|
1625
|
+
section.style.display = 'none';
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Exclude "recent" commits (noise) and deduplicate with "이어하기"
|
|
1630
|
+
const workTitles = new Set([...playgrounds.values()].map(p => p.branch));
|
|
1631
|
+
const filtered = suggestions
|
|
1632
|
+
.filter(item => item.type !== 'recent')
|
|
1633
|
+
.filter(item => !workTitles.has(item.action))
|
|
1634
|
+
.slice(0, 5);
|
|
1635
|
+
|
|
1636
|
+
if (filtered.length === 0) {
|
|
1637
|
+
section.style.display = 'none';
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
const iconMap = { issue: '🔵', pr: '🟡' };
|
|
1642
|
+
list.innerHTML = filtered.map(item => {
|
|
1643
|
+
const icon = iconMap[item.type] || '⚪';
|
|
1644
|
+
return `
|
|
1645
|
+
<div class="portal-work-item">
|
|
1646
|
+
<div class="portal-work-left">
|
|
1647
|
+
<span class="portal-work-icon">${icon}</span>
|
|
1648
|
+
<div>
|
|
1649
|
+
<div class="portal-work-title">${escHtml(item.title)}</div>
|
|
1650
|
+
</div>
|
|
1651
|
+
</div>
|
|
1652
|
+
</div>`;
|
|
1653
|
+
}).join('');
|
|
1654
|
+
section.style.display = '';
|
|
1655
|
+
} catch {
|
|
1656
|
+
section.style.display = 'none';
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async function loadPortal() {
|
|
1660
|
+
const workList = document.getElementById('portal-work');
|
|
1661
|
+
if (!workList) return;
|
|
1662
|
+
|
|
1663
|
+
try {
|
|
1664
|
+
const work = await api('GET', '/api/my-work');
|
|
1665
|
+
|
|
1666
|
+
const workSection = document.getElementById('portal-work-section');
|
|
1667
|
+
if (work.length === 0) {
|
|
1668
|
+
if (workSection) workSection.style.display = 'none';
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (workSection) workSection.style.display = '';
|
|
1672
|
+
|
|
1673
|
+
workList.innerHTML = work.slice(0, 3).map(item => {
|
|
1674
|
+
if (item.type === 'pr') {
|
|
1675
|
+
const statusLabel = item.isDraft ? '초안'
|
|
1676
|
+
: item.reviewStatus === 'APPROVED' ? '승인됨'
|
|
1677
|
+
: item.reviewStatus === 'CHANGES_REQUESTED' ? '수정 요청'
|
|
1678
|
+
: '팀이 보는 중';
|
|
1679
|
+
const statusClass = item.isDraft ? 'draft'
|
|
1680
|
+
: item.reviewStatus === 'APPROVED' ? 'approved'
|
|
1681
|
+
: item.reviewStatus === 'CHANGES_REQUESTED' ? 'changes'
|
|
1682
|
+
: 'pending';
|
|
1683
|
+
const timeAgo = new Date(item.updatedAt).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
|
1684
|
+
|
|
1685
|
+
return `
|
|
1686
|
+
<div class="portal-work-item" onclick="${item.camp ? `enterWorkspace('${escHtml(item.camp)}')` : `window.open('${escHtml(item.prUrl)}','_blank')`}">
|
|
1687
|
+
<div class="portal-work-left">
|
|
1688
|
+
<span class="portal-work-icon">🟡</span>
|
|
1689
|
+
<div>
|
|
1690
|
+
<div class="portal-work-title">${escHtml(item.title)}</div>
|
|
1691
|
+
<div class="portal-work-meta">PR #${item.prNumber} · ${timeAgo}</div>
|
|
1692
|
+
</div>
|
|
1693
|
+
</div>
|
|
1694
|
+
<span class="portal-work-status portal-status-${statusClass}">${statusLabel}</span>
|
|
1695
|
+
</div>`;
|
|
1696
|
+
} else {
|
|
1697
|
+
return `
|
|
1698
|
+
<div class="portal-work-item" onclick="enterWorkspace('${escHtml(item.camp)}')">
|
|
1699
|
+
<div class="portal-work-left">
|
|
1700
|
+
<span class="portal-work-icon">🟢</span>
|
|
1701
|
+
<div>
|
|
1702
|
+
<div class="portal-work-title">${escHtml(item.title)}</div>
|
|
1703
|
+
<div class="portal-work-meta">${escHtml(item.branch)}</div>
|
|
1704
|
+
</div>
|
|
1705
|
+
</div>
|
|
1706
|
+
<span class="portal-work-status portal-status-active">작업 이어하기</span>
|
|
1707
|
+
</div>`;
|
|
1708
|
+
}
|
|
1709
|
+
}).join('');
|
|
1710
|
+
} catch (err) {
|
|
1711
|
+
workList.innerHTML = '<div class="portal-empty">작업 목록을 불러올 수 없습니다</div>';
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
window.quickStart = async function quickStart() {
|
|
1716
|
+
const input = document.getElementById('quickstart-input');
|
|
1717
|
+
const description = input.value.trim();
|
|
1718
|
+
if (!description) { toast('뭘 하고 싶은지 입력해주세요!', 'error'); return; }
|
|
1719
|
+
|
|
1720
|
+
// 즉시 로딩 상태 표시
|
|
1721
|
+
const btn = input.nextElementSibling;
|
|
1722
|
+
const origText = btn.textContent;
|
|
1723
|
+
btn.disabled = true;
|
|
1724
|
+
btn.textContent = '만드는 중...';
|
|
1725
|
+
input.disabled = true;
|
|
1726
|
+
toast('캠프를 만들고 있습니다... (의존성 설치 중)', 'info');
|
|
1727
|
+
|
|
1728
|
+
try {
|
|
1729
|
+
const result = await api('POST', '/api/quick-start', { description });
|
|
1730
|
+
input.value = '';
|
|
1731
|
+
toast(`캠프 "${result.name}" 생성 완료!`, 'success');
|
|
1732
|
+
await loadPortal();
|
|
1733
|
+
renderAll();
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
toast(`생성 실패: ${err.message}`, 'error');
|
|
1736
|
+
} finally {
|
|
1737
|
+
btn.disabled = false;
|
|
1738
|
+
btn.textContent = origText;
|
|
1739
|
+
input.disabled = false;
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
window.autoFix = async function autoFix(name) {
|
|
1745
|
+
toast('문제를 분석하고 있습니다...', 'info');
|
|
1746
|
+
try {
|
|
1747
|
+
const result = await api('POST', `/api/playgrounds/${name}/auto-fix`);
|
|
1748
|
+
if (result.fixed) {
|
|
1749
|
+
toast(`자동 수정 완료: ${result.description}`, 'success');
|
|
1750
|
+
} else {
|
|
1751
|
+
toast(result.description, 'error');
|
|
1752
|
+
}
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
toast(`자동 수정 실패: ${err.message}`, 'error');
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
// ---------------------------------------------------------------------------
|
|
1759
|
+
// Onboarding Tutorial
|
|
1760
|
+
// ---------------------------------------------------------------------------
|
|
1761
|
+
|
|
1762
|
+
const ONBOARDING_KEY = 'sanjang-onboarded';
|
|
1763
|
+
|
|
1764
|
+
const onboardingSteps = [
|
|
1765
|
+
{
|
|
1766
|
+
target: '#quickstart-input',
|
|
1767
|
+
title: '캠프 만들기',
|
|
1768
|
+
text: '하고 싶은 걸 입력하면 AI가 캠프를 자동으로 만들어줘요.',
|
|
1769
|
+
position: 'bottom',
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
target: '#ws-preview',
|
|
1773
|
+
title: '프리뷰 확인',
|
|
1774
|
+
text: '캠프에 들어가면 전체화면으로 프리뷰를 볼 수 있어요.',
|
|
1775
|
+
position: 'center',
|
|
1776
|
+
waitForWorkspace: true,
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
target: '#ws-save-btn',
|
|
1780
|
+
title: '세이브하기',
|
|
1781
|
+
text: '변경사항이 있으면 세이브 버튼으로 저장해요. 게임 세이브처럼요!',
|
|
1782
|
+
position: 'left',
|
|
1783
|
+
waitForWorkspace: true,
|
|
1784
|
+
},
|
|
1785
|
+
];
|
|
1786
|
+
|
|
1787
|
+
function showOnboarding() {
|
|
1788
|
+
if (localStorage.getItem(ONBOARDING_KEY)) return;
|
|
1789
|
+
let step = 0;
|
|
1790
|
+
|
|
1791
|
+
function show() {
|
|
1792
|
+
// Remove previous
|
|
1793
|
+
document.querySelector('.onboarding-overlay')?.remove();
|
|
1794
|
+
|
|
1795
|
+
if (step >= onboardingSteps.length) {
|
|
1796
|
+
localStorage.setItem(ONBOARDING_KEY, '1');
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const s = onboardingSteps[step];
|
|
1801
|
+
|
|
1802
|
+
// Skip workspace steps if not in workspace
|
|
1803
|
+
if (s.waitForWorkspace && !currentWorkspace) {
|
|
1804
|
+
step++;
|
|
1805
|
+
show();
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const el = document.querySelector(s.target);
|
|
1810
|
+
if (!el) { step++; show(); return; }
|
|
1811
|
+
|
|
1812
|
+
const overlay = document.createElement('div');
|
|
1813
|
+
overlay.className = 'onboarding-overlay';
|
|
1814
|
+
|
|
1815
|
+
const rect = el.getBoundingClientRect();
|
|
1816
|
+
const highlight = document.createElement('div');
|
|
1817
|
+
highlight.className = 'onboarding-highlight';
|
|
1818
|
+
highlight.style.top = `${rect.top - 4}px`;
|
|
1819
|
+
highlight.style.left = `${rect.left - 4}px`;
|
|
1820
|
+
highlight.style.width = `${rect.width + 8}px`;
|
|
1821
|
+
highlight.style.height = `${rect.height + 8}px`;
|
|
1822
|
+
overlay.appendChild(highlight);
|
|
1823
|
+
|
|
1824
|
+
const tooltip = document.createElement('div');
|
|
1825
|
+
tooltip.className = 'onboarding-tooltip';
|
|
1826
|
+
tooltip.innerHTML = `
|
|
1827
|
+
<div class="onboarding-title">${s.title}</div>
|
|
1828
|
+
<div class="onboarding-text">${s.text}</div>
|
|
1829
|
+
<div class="onboarding-actions">
|
|
1830
|
+
<span class="onboarding-step">${step + 1}/${onboardingSteps.length}</span>
|
|
1831
|
+
<button class="btn btn-ghost btn-sm" onclick="skipOnboarding()">건너뛰기</button>
|
|
1832
|
+
<button class="btn btn-primary btn-sm" onclick="nextOnboardingStep()">${step === onboardingSteps.length - 1 ? '완료' : '다음'}</button>
|
|
1833
|
+
</div>`;
|
|
1834
|
+
|
|
1835
|
+
// Position tooltip near target
|
|
1836
|
+
if (s.position === 'bottom') {
|
|
1837
|
+
tooltip.style.top = `${rect.bottom + 12}px`;
|
|
1838
|
+
tooltip.style.left = `${Math.max(12, rect.left)}px`;
|
|
1839
|
+
} else if (s.position === 'left') {
|
|
1840
|
+
tooltip.style.top = `${rect.top}px`;
|
|
1841
|
+
tooltip.style.right = `${window.innerWidth - rect.left + 12}px`;
|
|
1842
|
+
} else {
|
|
1843
|
+
tooltip.style.top = '50%';
|
|
1844
|
+
tooltip.style.left = '50%';
|
|
1845
|
+
tooltip.style.transform = 'translate(-50%, -50%)';
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
overlay.appendChild(tooltip);
|
|
1849
|
+
document.body.appendChild(overlay);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
window.nextOnboardingStep = function() {
|
|
1853
|
+
step++;
|
|
1854
|
+
show();
|
|
1855
|
+
};
|
|
1856
|
+
|
|
1857
|
+
window.skipOnboarding = function() {
|
|
1858
|
+
document.querySelector('.onboarding-overlay')?.remove();
|
|
1859
|
+
localStorage.setItem(ONBOARDING_KEY, '1');
|
|
1860
|
+
};
|
|
1861
|
+
|
|
1862
|
+
// Start first step (only if on portal/quickstart visible)
|
|
1863
|
+
show();
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// ---------------------------------------------------------------------------
|
|
1867
|
+
// Init
|
|
1868
|
+
// ---------------------------------------------------------------------------
|
|
1869
|
+
|
|
1870
|
+
async function init() {
|
|
1871
|
+
try {
|
|
1872
|
+
const pgs = await api('GET', '/api/playgrounds');
|
|
1873
|
+
for (const pg of pgs) {
|
|
1874
|
+
playgrounds.set(pg.name, pg);
|
|
1875
|
+
}
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
toast(`캠프 목록 로드 실패: ${err.message}`, 'error');
|
|
1878
|
+
}
|
|
1879
|
+
renderAll();
|
|
1880
|
+
loadPortal();
|
|
1881
|
+
loadSuggestions();
|
|
1882
|
+
connectWs();
|
|
1883
|
+
|
|
1884
|
+
// Show onboarding for first-time users
|
|
1885
|
+
setTimeout(showOnboarding, 500);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
document.addEventListener('DOMContentLoaded', init);
|