ralphflow 0.5.0 → 0.5.2
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/README.md +2 -0
- package/dist/{chunk-TCCMQDVT.js → chunk-DOC64TD6.js} +32 -2
- package/dist/ralphflow.js +584 -24
- package/dist/{server-DOSLU36L.js → server-EX5MWYW4.js} +210 -10
- package/package.json +6 -2
- package/src/dashboard/ui/app.js +203 -0
- package/src/dashboard/ui/archives.js +167 -0
- package/src/dashboard/ui/index.html +2 -3210
- package/src/dashboard/ui/loop-detail.js +880 -0
- package/src/dashboard/ui/notifications.js +151 -0
- package/src/dashboard/ui/prompt-builder.js +362 -0
- package/src/dashboard/ui/sidebar.js +97 -0
- package/src/dashboard/ui/state.js +54 -0
- package/src/dashboard/ui/styles.css +2140 -0
- package/src/dashboard/ui/templates.js +1858 -0
- package/src/dashboard/ui/utils.js +115 -0
- package/src/templates/code-implementation/loops/00-story-loop/prompt.md +73 -11
- package/src/templates/code-implementation/loops/01-tasks-loop/prompt.md +51 -2
- package/src/templates/code-implementation/loops/02-delivery-loop/prompt.md +48 -4
- package/src/templates/research/loops/00-discovery-loop/prompt.md +58 -5
- package/src/templates/research/loops/01-research-loop/prompt.md +44 -2
- package/src/templates/research/loops/02-story-loop/prompt.md +42 -1
- package/src/templates/research/loops/03-document-loop/prompt.md +42 -1
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
// Main content rendering: pipeline, loop detail panels, prompt editing,
|
|
2
|
+
// tracker/config viewers, model selector, and app modals.
|
|
3
|
+
|
|
4
|
+
import { $, state, dom, actions } from './state.js';
|
|
5
|
+
import { esc, fetchJson, renderMarkdown, calculatePipelineProgress, getLoopStatusClass, formatModelName, extractNotifMessage } from './utils.js';
|
|
6
|
+
import { renderDecisionGroups, dismissNotification, dismissDecision } from './notifications.js';
|
|
7
|
+
import { switchAppTab, loadArchives } from './archives.js';
|
|
8
|
+
|
|
9
|
+
// -----------------------------------------------------------------------
|
|
10
|
+
// Main content renderer
|
|
11
|
+
// -----------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export function renderContent() {
|
|
14
|
+
if (state.currentPage === 'templates') {
|
|
15
|
+
actions.renderTemplatesPage();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!state.selectedApp) {
|
|
19
|
+
dom.content.innerHTML = '<div class="content-empty">Select an app to view details</div>';
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const app = state.selectedApp;
|
|
24
|
+
const currentLoop = app.loops.find(l => l.key === state.selectedLoop);
|
|
25
|
+
|
|
26
|
+
let html = '';
|
|
27
|
+
|
|
28
|
+
// App header
|
|
29
|
+
html += `<div class="app-header">
|
|
30
|
+
<div style="display:flex;align-items:center;gap:10px;justify-content:space-between;width:100%">
|
|
31
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
32
|
+
<h2>${esc(app.appName)}</h2>
|
|
33
|
+
<span class="app-type-badge">${esc(app.appType)}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div style="display:flex;gap:6px">
|
|
36
|
+
<button class="btn btn-muted" style="font-size:12px;padding:4px 10px" onclick="openArchiveAppModal('${esc(app.appName)}')">Archive</button>
|
|
37
|
+
<button class="btn btn-danger" style="font-size:12px;padding:4px 10px" onclick="openDeleteAppModal('${esc(app.appName)}')">Delete</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
${app.description ? `<div class="app-desc">${esc(app.description)}</div>` : ''}
|
|
41
|
+
</div>`;
|
|
42
|
+
|
|
43
|
+
// App-level tabs: Loops | Archives
|
|
44
|
+
html += `<div class="app-tabs">
|
|
45
|
+
<button class="app-tab${state.activeAppTab === 'loops' ? ' active' : ''}" data-app-tab="loops">Loops</button>
|
|
46
|
+
<button class="app-tab${state.activeAppTab === 'archives' ? ' active' : ''}" data-app-tab="archives">Archives</button>
|
|
47
|
+
</div>`;
|
|
48
|
+
|
|
49
|
+
if (state.activeAppTab === 'archives') {
|
|
50
|
+
html += '<div id="archivesContainer">Loading archives...</div>';
|
|
51
|
+
dom.content.innerHTML = html;
|
|
52
|
+
|
|
53
|
+
// Bind app tab clicks
|
|
54
|
+
dom.content.querySelectorAll('.app-tab').forEach(tab => {
|
|
55
|
+
tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
loadArchives(app.appName);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Loops tab content ---
|
|
63
|
+
|
|
64
|
+
// Pipeline
|
|
65
|
+
const pipelineProgress = calculatePipelineProgress(app.loops);
|
|
66
|
+
const progressByKey = {};
|
|
67
|
+
pipelineProgress.perLoop.forEach(p => { progressByKey[p.key] = p; });
|
|
68
|
+
html += '<div class="section"><div class="section-title">Pipeline</div><div class="pipeline">';
|
|
69
|
+
app.loops.forEach((loop, i) => {
|
|
70
|
+
if (i > 0) {
|
|
71
|
+
const prevComplete = getLoopStatusClass(app.loops[i - 1]) === 'complete';
|
|
72
|
+
const prevOut = app.loops[i - 1].feeds || [];
|
|
73
|
+
const curIn = loop.fed_by || [];
|
|
74
|
+
const sharedFiles = prevOut.filter(f => curIn.includes(f));
|
|
75
|
+
if (sharedFiles.length > 0) {
|
|
76
|
+
html += `<div class="pipeline-connector-wrap">
|
|
77
|
+
<div class="pipeline-connector${prevComplete ? ' complete' : ''}"></div>
|
|
78
|
+
<span class="connector-file" title="${esc(sharedFiles.join(', '))}">${esc(sharedFiles.join(', '))}</span>
|
|
79
|
+
</div>`;
|
|
80
|
+
} else {
|
|
81
|
+
html += `<div class="pipeline-connector${prevComplete ? ' complete' : ''}"></div>`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const statusClass = getLoopStatusClass(loop);
|
|
85
|
+
const isSelected = loop.key === state.selectedLoop;
|
|
86
|
+
const modelDisplay = formatModelName(loop.model);
|
|
87
|
+
const lp = progressByKey[loop.key] || { completed: 0, total: 0, fraction: 0 };
|
|
88
|
+
const progressText = lp.total > 0 ? `${lp.completed}/${lp.total}` : '\u2014';
|
|
89
|
+
const progressPct = Math.round(lp.fraction * 100);
|
|
90
|
+
const fedBy = loop.fed_by || [];
|
|
91
|
+
const feeds = loop.feeds || [];
|
|
92
|
+
const inputIo = fedBy.length > 0 ? `<div class="node-io node-io-in">${fedBy.map(f => `<span class="node-io-label" title="input: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
|
|
93
|
+
const outputIo = feeds.length > 0 ? `<div class="node-io node-io-out">${feeds.map(f => `<span class="node-io-label" title="output: ${esc(f)}">${esc(f)}</span>`).join('')}</div>` : '';
|
|
94
|
+
html += `<div class="pipeline-node${isSelected ? ' selected' : ''}" data-loop="${esc(loop.key)}">
|
|
95
|
+
${inputIo}
|
|
96
|
+
<span class="node-name">${esc(loop.name)}</span>
|
|
97
|
+
<span class="node-status-row">
|
|
98
|
+
<span class="node-status ${statusClass}">${statusClass}</span>
|
|
99
|
+
<span class="node-model-sep">·</span>
|
|
100
|
+
<span class="node-model${modelDisplay ? '' : ' node-model-default'}">${modelDisplay ? esc(modelDisplay) : 'default'}</span>
|
|
101
|
+
</span>
|
|
102
|
+
<div class="node-progress">
|
|
103
|
+
<span class="node-progress-text">${progressText}</span>
|
|
104
|
+
<div class="node-progress-bar"><div class="node-progress-fill" style="width:${progressPct}%"></div></div>
|
|
105
|
+
</div>
|
|
106
|
+
${outputIo}
|
|
107
|
+
</div>`;
|
|
108
|
+
});
|
|
109
|
+
html += '</div></div>';
|
|
110
|
+
|
|
111
|
+
// Commands section
|
|
112
|
+
html += '<div class="section"><div class="section-title">Commands</div><div class="commands-list">';
|
|
113
|
+
app.loops.forEach(loop => {
|
|
114
|
+
const alias = loop.key.replace(/-loop$/, '');
|
|
115
|
+
let cmd = `npx ralphflow run ${alias} -f ${app.appName}`;
|
|
116
|
+
if (loop.multiAgent) cmd += ' --multi-agent';
|
|
117
|
+
if (loop.model) cmd += ` --model ${loop.model}`;
|
|
118
|
+
html += `<div class="cmd-item">
|
|
119
|
+
<span class="cmd-text">${esc(cmd)}</span>
|
|
120
|
+
<button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
|
|
121
|
+
</div>`;
|
|
122
|
+
});
|
|
123
|
+
const e2eCmd = `npx ralphflow e2e -f ${app.appName}`;
|
|
124
|
+
html += `<div class="cmd-item">
|
|
125
|
+
<span class="cmd-text">${esc(e2eCmd)}</span>
|
|
126
|
+
<button class="cmd-copy" data-cmd="${esc(e2eCmd)}">Copy</button>
|
|
127
|
+
</div>`;
|
|
128
|
+
html += '</div></div>';
|
|
129
|
+
|
|
130
|
+
// Loop detail — two-column three-panel layout
|
|
131
|
+
if (currentLoop) {
|
|
132
|
+
const st = currentLoop.status || {};
|
|
133
|
+
|
|
134
|
+
html += '<div class="panel-grid">';
|
|
135
|
+
|
|
136
|
+
// Left column: Interactive + Progress
|
|
137
|
+
html += '<div class="panel-col-left">';
|
|
138
|
+
|
|
139
|
+
// Interactive panel — Notifications + Decisions
|
|
140
|
+
const loopNotifs = state.notificationsList.filter(n => n.app === app.appName && n.loop === currentLoop.key);
|
|
141
|
+
const loopDecisions = state.decisionsList.filter(d => d.app === app.appName && d.loop === currentLoop.key);
|
|
142
|
+
const hasNotifs = loopNotifs.length > 0;
|
|
143
|
+
const hasDecisions = loopDecisions.length > 0;
|
|
144
|
+
const interactiveTotal = loopNotifs.length + loopDecisions.length;
|
|
145
|
+
const hasInteractive = hasNotifs || hasDecisions;
|
|
146
|
+
html += `<div class="panel panel-interactive${hasInteractive ? ' has-notifs' : ''}">
|
|
147
|
+
<div class="panel-header">Interactive${interactiveTotal > 0 ? ' <span style="color:var(--accent)">(' + interactiveTotal + ')</span>' : ''}</div>
|
|
148
|
+
<div class="panel-body">`;
|
|
149
|
+
|
|
150
|
+
if (!hasInteractive) {
|
|
151
|
+
html += `<span class="bell-icon">🔔</span><span>No notifications or decisions</span>`;
|
|
152
|
+
} else {
|
|
153
|
+
// Notifications section
|
|
154
|
+
if (hasNotifs) {
|
|
155
|
+
html += '<div class="interactive-section-header">Notifications</div>';
|
|
156
|
+
for (const n of loopNotifs) {
|
|
157
|
+
const time = new Date(n.timestamp).toLocaleTimeString();
|
|
158
|
+
const msg = extractNotifMessage(n.payload);
|
|
159
|
+
html += `<div class="notif-card" data-notif-id="${esc(n.id)}">
|
|
160
|
+
<span class="notif-time">${esc(time)}</span>
|
|
161
|
+
<span class="notif-msg">${esc(msg)}</span>
|
|
162
|
+
<button class="notif-dismiss" data-dismiss-id="${esc(n.id)}">×</button>
|
|
163
|
+
</div>`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Decisions section with nested grouping
|
|
168
|
+
if (hasDecisions) {
|
|
169
|
+
html += '<div class="interactive-section-header">Decisions</div>';
|
|
170
|
+
html += renderDecisionGroups(loopDecisions);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
html += '</div></div>';
|
|
174
|
+
|
|
175
|
+
// Progress panel
|
|
176
|
+
html += `<div class="panel panel-progress">
|
|
177
|
+
<div class="panel-header">Progress</div>
|
|
178
|
+
<div class="panel-body">
|
|
179
|
+
<div class="loop-meta">
|
|
180
|
+
<div class="meta-card"><div class="meta-label">Stage</div><div class="meta-value">${esc(st.stage || '—')}</div></div>
|
|
181
|
+
<div class="meta-card"><div class="meta-label">Active</div><div class="meta-value">${esc(st.active || 'none')}</div></div>
|
|
182
|
+
<div class="meta-card">
|
|
183
|
+
<div class="meta-label">Progress</div>
|
|
184
|
+
<div class="meta-value">${st.completed || 0}/${st.total || 0}</div>
|
|
185
|
+
<div class="progress-bar"><div class="progress-fill" style="width:${st.total ? (st.completed / st.total * 100) : 0}%"></div></div>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="meta-card"><div class="meta-label">Stages</div><div class="meta-value" style="font-size:11px">${(currentLoop.stages || []).join(' → ')}</div></div>
|
|
188
|
+
</div>`;
|
|
189
|
+
|
|
190
|
+
// Agent table
|
|
191
|
+
if (st.agents && st.agents.length > 0) {
|
|
192
|
+
html += `<div style="margin-top:16px">
|
|
193
|
+
<table class="agent-table">
|
|
194
|
+
<thead><tr><th>Agent</th><th>Active Task</th><th>Stage</th><th>Heartbeat</th></tr></thead>
|
|
195
|
+
<tbody>`;
|
|
196
|
+
for (const ag of st.agents) {
|
|
197
|
+
html += `<tr><td>${esc(ag.name)}</td><td>${esc(ag.activeTask)}</td><td>${esc(ag.stage)}</td><td>${esc(ag.lastHeartbeat)}</td></tr>`;
|
|
198
|
+
}
|
|
199
|
+
html += '</tbody></table></div>';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Tracker viewer (inside Progress panel)
|
|
203
|
+
html += `<div class="tracker-viewer" id="trackerViewer">Loading...</div>`;
|
|
204
|
+
|
|
205
|
+
html += '</div></div>'; // close .panel-body + .panel-progress
|
|
206
|
+
html += '</div>'; // close .panel-col-left
|
|
207
|
+
|
|
208
|
+
// Right column: Edit panel with tabs
|
|
209
|
+
html += `<div class="panel panel-edit">
|
|
210
|
+
<div class="edit-tabs">
|
|
211
|
+
<button class="edit-tab${state.activeEditTab === 'prompt' ? ' active' : ''}" data-tab="prompt">Prompt</button>
|
|
212
|
+
<button class="edit-tab${state.activeEditTab === 'tracker' ? ' active' : ''}" data-tab="tracker">Tracker</button>
|
|
213
|
+
<button class="edit-tab${state.activeEditTab === 'config' ? ' active' : ''}" data-tab="config">Config</button>
|
|
214
|
+
<div class="model-selector-wrap">
|
|
215
|
+
<label>Model</label>
|
|
216
|
+
<select class="model-selector" id="modelSelector">
|
|
217
|
+
<option value="">Default</option>
|
|
218
|
+
<option value="claude-opus-4-6">claude-opus-4-6</option>
|
|
219
|
+
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
|
|
220
|
+
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
|
|
221
|
+
</select>
|
|
222
|
+
<span class="model-save-ok" id="modelSaveOk">Saved</span>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="panel-body" id="editTabContent"></div>
|
|
226
|
+
</div>`;
|
|
227
|
+
|
|
228
|
+
html += '</div>'; // close .panel-grid
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
dom.content.innerHTML = html;
|
|
232
|
+
|
|
233
|
+
// Bind app-level tab clicks
|
|
234
|
+
dom.content.querySelectorAll('.app-tab').forEach(tab => {
|
|
235
|
+
tab.addEventListener('click', () => switchAppTab(tab.dataset.appTab));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Bind pipeline node clicks
|
|
239
|
+
dom.content.querySelectorAll('.pipeline-node').forEach(el => {
|
|
240
|
+
el.addEventListener('click', () => actions.selectLoop(el.dataset.loop));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Bind command copy buttons
|
|
244
|
+
dom.content.querySelectorAll('.commands-list .cmd-copy').forEach(btn => {
|
|
245
|
+
btn.addEventListener('click', () => {
|
|
246
|
+
const cmd = btn.dataset.cmd || '';
|
|
247
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
248
|
+
const orig = btn.textContent;
|
|
249
|
+
btn.textContent = 'Copied!';
|
|
250
|
+
setTimeout(() => { btn.textContent = orig; }, 1500);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Bind notification dismiss buttons
|
|
256
|
+
dom.content.querySelectorAll('.notif-dismiss').forEach(btn => {
|
|
257
|
+
btn.addEventListener('click', () => dismissNotification(btn.dataset.dismissId));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Bind decision dismiss buttons
|
|
261
|
+
dom.content.querySelectorAll('.decision-dismiss').forEach(btn => {
|
|
262
|
+
btn.addEventListener('click', () => dismissDecision(btn.dataset.dismissDecisionId));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Bind decision group collapse/expand
|
|
266
|
+
dom.content.querySelectorAll('.decision-group-header').forEach(hdr => {
|
|
267
|
+
hdr.addEventListener('click', () => {
|
|
268
|
+
const groupId = hdr.dataset.decisionGroup;
|
|
269
|
+
const body = document.getElementById(groupId);
|
|
270
|
+
const chevron = hdr.querySelector('.group-chevron');
|
|
271
|
+
if (body && chevron) {
|
|
272
|
+
body.classList.toggle('collapsed');
|
|
273
|
+
chevron.classList.toggle('expanded');
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Bind edit tabs + load content
|
|
279
|
+
if (currentLoop) {
|
|
280
|
+
dom.content.querySelectorAll('.edit-tab').forEach(tab => {
|
|
281
|
+
tab.addEventListener('click', () => switchEditTab(tab.dataset.tab, app.appName, currentLoop.key));
|
|
282
|
+
});
|
|
283
|
+
renderEditTabContent(app.appName, currentLoop.key);
|
|
284
|
+
loadTracker(app.appName, currentLoop.key);
|
|
285
|
+
loadModelSelector(app.appName, currentLoop.key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
// Edit panel: prompt, tracker, config tabs
|
|
291
|
+
// -----------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
function bindPromptEditor(appName, loopKey) {
|
|
294
|
+
const editor = $('#promptEditor');
|
|
295
|
+
if (!editor) return;
|
|
296
|
+
|
|
297
|
+
editor.addEventListener('input', () => {
|
|
298
|
+
state.promptDirty = editor.value !== state.promptOriginal;
|
|
299
|
+
updateDirtyState();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
editor.addEventListener('keydown', (e) => {
|
|
303
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
savePrompt(appName, loopKey);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const saveBtn = $('#savePromptBtn');
|
|
310
|
+
const resetBtn = $('#resetPromptBtn');
|
|
311
|
+
if (saveBtn) saveBtn.addEventListener('click', () => savePrompt(appName, loopKey));
|
|
312
|
+
if (resetBtn) resetBtn.addEventListener('click', () => {
|
|
313
|
+
editor.value = state.promptOriginal;
|
|
314
|
+
state.promptDirty = false;
|
|
315
|
+
updateDirtyState();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function loadPrompt(appName, loopKey) {
|
|
320
|
+
const editor = $('#promptEditor');
|
|
321
|
+
if (!editor) return;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
|
|
325
|
+
editor.value = data.content || '';
|
|
326
|
+
state.promptOriginal = editor.value;
|
|
327
|
+
state.promptDirty = false;
|
|
328
|
+
updateDirtyState();
|
|
329
|
+
} catch {
|
|
330
|
+
editor.value = '(Error loading prompt)';
|
|
331
|
+
}
|
|
332
|
+
bindPromptEditor(appName, loopKey);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadPromptPreview(appName, loopKey) {
|
|
336
|
+
const preview = $('#promptPreview');
|
|
337
|
+
if (!preview) return;
|
|
338
|
+
try {
|
|
339
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`);
|
|
340
|
+
state.promptOriginal = data.content || '';
|
|
341
|
+
preview.innerHTML = renderMarkdown(state.promptOriginal);
|
|
342
|
+
} catch {
|
|
343
|
+
preview.innerHTML = '<p style="color:var(--text-dim)">(Error loading prompt)</p>';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function savePrompt(appName, loopKey) {
|
|
348
|
+
const editor = $('#promptEditor');
|
|
349
|
+
if (!editor || !state.promptDirty) return;
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await fetch(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/prompt`, {
|
|
353
|
+
method: 'PUT',
|
|
354
|
+
headers: { 'Content-Type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ content: editor.value }),
|
|
356
|
+
});
|
|
357
|
+
state.promptOriginal = editor.value;
|
|
358
|
+
state.promptDirty = false;
|
|
359
|
+
updateDirtyState();
|
|
360
|
+
const saveOk = $('#saveOk');
|
|
361
|
+
if (saveOk) {
|
|
362
|
+
saveOk.style.display = 'inline';
|
|
363
|
+
setTimeout(() => { saveOk.style.display = 'none'; }, 2000);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
alert('Failed to save prompt');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function updateDirtyState() {
|
|
371
|
+
const saveBtn = $('#savePromptBtn');
|
|
372
|
+
const resetBtn = $('#resetPromptBtn');
|
|
373
|
+
const indicator = $('#dirtyIndicator');
|
|
374
|
+
if (saveBtn) saveBtn.disabled = !state.promptDirty;
|
|
375
|
+
if (resetBtn) resetBtn.disabled = !state.promptDirty;
|
|
376
|
+
if (indicator) indicator.style.display = state.promptDirty ? 'inline' : 'none';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function loadTracker(appName, loopKey) {
|
|
380
|
+
const viewer = $('#trackerViewer');
|
|
381
|
+
if (!viewer) return;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
|
|
385
|
+
viewer.innerHTML = renderMarkdown(data.content || '(empty)');
|
|
386
|
+
} catch {
|
|
387
|
+
viewer.innerHTML = '(No tracker file found)';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function switchEditTab(tab, appName, loopKey) {
|
|
392
|
+
if (tab === state.activeEditTab) return;
|
|
393
|
+
if (state.activeEditTab === 'prompt') {
|
|
394
|
+
const editor = $('#promptEditor');
|
|
395
|
+
if (editor) {
|
|
396
|
+
state.cachedPromptValue = editor.value;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
state.activeEditTab = tab;
|
|
400
|
+
document.querySelectorAll('.edit-tab').forEach(t => {
|
|
401
|
+
t.classList.toggle('active', t.dataset.tab === tab);
|
|
402
|
+
});
|
|
403
|
+
renderEditTabContent(appName, loopKey);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderEditTabContent(appName, loopKey) {
|
|
407
|
+
const container = $('#editTabContent');
|
|
408
|
+
if (!container) return;
|
|
409
|
+
|
|
410
|
+
if (state.activeEditTab === 'prompt') {
|
|
411
|
+
const isRead = state.promptViewMode === 'read';
|
|
412
|
+
const isEdit = state.promptViewMode === 'edit';
|
|
413
|
+
container.innerHTML = `
|
|
414
|
+
<div class="prompt-mode-toggle">
|
|
415
|
+
<button class="prompt-mode-btn${isRead ? ' active' : ''}" data-mode="read">Read</button>
|
|
416
|
+
<button class="prompt-mode-btn${isEdit ? ' active' : ''}" data-mode="edit">Edit</button>
|
|
417
|
+
</div>
|
|
418
|
+
${isEdit ? `<div class="editor-wrap">
|
|
419
|
+
<textarea class="editor" id="promptEditor" placeholder="Loading..."></textarea>
|
|
420
|
+
<div class="editor-actions">
|
|
421
|
+
<button class="btn btn-primary" id="savePromptBtn" disabled>Save</button>
|
|
422
|
+
<button class="btn" id="resetPromptBtn" disabled>Reset</button>
|
|
423
|
+
<span class="dirty-indicator" id="dirtyIndicator" style="display:none">Unsaved changes</span>
|
|
424
|
+
<span class="save-ok" id="saveOk" style="display:none">Saved</span>
|
|
425
|
+
</div>
|
|
426
|
+
</div>` : `<div class="prompt-preview" id="promptPreview">Loading...</div>`}`;
|
|
427
|
+
// Bind toggle buttons
|
|
428
|
+
container.querySelectorAll('.prompt-mode-btn').forEach(btn => {
|
|
429
|
+
btn.addEventListener('click', () => {
|
|
430
|
+
if (btn.dataset.mode === state.promptViewMode) return;
|
|
431
|
+
// Cache editor value before switching away from edit
|
|
432
|
+
if (state.promptViewMode === 'edit') {
|
|
433
|
+
const editor = $('#promptEditor');
|
|
434
|
+
if (editor) state.cachedPromptValue = editor.value;
|
|
435
|
+
}
|
|
436
|
+
state.promptViewMode = btn.dataset.mode;
|
|
437
|
+
renderEditTabContent(appName, loopKey);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
if (isEdit) {
|
|
441
|
+
if (state.cachedPromptValue !== null) {
|
|
442
|
+
const editor = $('#promptEditor');
|
|
443
|
+
if (editor) {
|
|
444
|
+
editor.value = state.cachedPromptValue;
|
|
445
|
+
updateDirtyState();
|
|
446
|
+
bindPromptEditor(appName, loopKey);
|
|
447
|
+
}
|
|
448
|
+
state.cachedPromptValue = null;
|
|
449
|
+
} else {
|
|
450
|
+
loadPrompt(appName, loopKey);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
// Read mode — render markdown preview
|
|
454
|
+
if (state.cachedPromptValue !== null || state.promptOriginal) {
|
|
455
|
+
const content = state.cachedPromptValue !== null ? state.cachedPromptValue : state.promptOriginal;
|
|
456
|
+
const preview = $('#promptPreview');
|
|
457
|
+
if (preview) preview.innerHTML = renderMarkdown(content);
|
|
458
|
+
} else {
|
|
459
|
+
// Need to fetch
|
|
460
|
+
loadPromptPreview(appName, loopKey);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} else if (state.activeEditTab === 'tracker') {
|
|
464
|
+
container.innerHTML = '<pre class="code-viewer" id="editTrackerViewer">Loading...</pre>';
|
|
465
|
+
loadEditTracker(appName, loopKey);
|
|
466
|
+
} else if (state.activeEditTab === 'config') {
|
|
467
|
+
container.innerHTML = '<pre class="code-viewer" id="editConfigViewer">Loading...</pre>';
|
|
468
|
+
loadEditConfig(appName);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function loadEditTracker(appName, loopKey) {
|
|
473
|
+
const viewer = $('#editTrackerViewer');
|
|
474
|
+
if (!viewer) return;
|
|
475
|
+
try {
|
|
476
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/loops/${encodeURIComponent(loopKey)}/tracker`);
|
|
477
|
+
viewer.textContent = data.content || '(empty)';
|
|
478
|
+
} catch {
|
|
479
|
+
viewer.textContent = '(No tracker file found)';
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function loadEditConfig(appName) {
|
|
484
|
+
const viewer = $('#editConfigViewer');
|
|
485
|
+
if (!viewer) return;
|
|
486
|
+
try {
|
|
487
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
|
|
488
|
+
viewer.textContent = data._rawYaml || JSON.stringify(data, null, 2);
|
|
489
|
+
} catch {
|
|
490
|
+
viewer.textContent = '(Error loading config)';
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function loadModelSelector(appName, loopKey) {
|
|
495
|
+
const selector = $('#modelSelector');
|
|
496
|
+
if (!selector) return;
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const data = await fetchJson(`/api/apps/${encodeURIComponent(appName)}/config`);
|
|
500
|
+
const loopConfig = data.loops && data.loops[loopKey];
|
|
501
|
+
const currentModel = loopConfig && loopConfig.model ? loopConfig.model : '';
|
|
502
|
+
selector.value = currentModel;
|
|
503
|
+
} catch {
|
|
504
|
+
// Leave at default
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
selector.addEventListener('change', () => changeModel(appName, loopKey, selector.value));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function changeModel(appName, loopKey, model) {
|
|
511
|
+
const saveOk = $('#modelSaveOk');
|
|
512
|
+
try {
|
|
513
|
+
await fetch(`/api/apps/${encodeURIComponent(appName)}/config/model`, {
|
|
514
|
+
method: 'PUT',
|
|
515
|
+
headers: { 'Content-Type': 'application/json' },
|
|
516
|
+
body: JSON.stringify({ loop: loopKey, model: model || null }),
|
|
517
|
+
});
|
|
518
|
+
if (saveOk) {
|
|
519
|
+
saveOk.classList.add('visible');
|
|
520
|
+
setTimeout(() => saveOk.classList.remove('visible'), 2000);
|
|
521
|
+
}
|
|
522
|
+
// Refresh config tab if it's currently visible
|
|
523
|
+
if (state.activeEditTab === 'config') {
|
|
524
|
+
loadEditConfig(appName);
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
alert('Failed to update model');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// -----------------------------------------------------------------------
|
|
532
|
+
// App modals: Delete, Archive, Create
|
|
533
|
+
// -----------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
export function openDeleteAppModal(appName) {
|
|
536
|
+
const existing = document.querySelector('.modal-overlay');
|
|
537
|
+
if (existing) existing.remove();
|
|
538
|
+
|
|
539
|
+
const overlay = document.createElement('div');
|
|
540
|
+
overlay.className = 'modal-overlay';
|
|
541
|
+
overlay.innerHTML = `
|
|
542
|
+
<div class="modal">
|
|
543
|
+
<div class="modal-header">
|
|
544
|
+
<h3>Delete App</h3>
|
|
545
|
+
<button class="modal-close" data-action="close">×</button>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="modal-body" id="deleteModalBody">
|
|
548
|
+
<p style="margin-bottom:12px">Are you sure you want to delete <strong>${esc(appName)}</strong>?</p>
|
|
549
|
+
<p style="color:var(--red);font-size:13px">This will permanently remove the app directory and all associated data. This action cannot be undone.</p>
|
|
550
|
+
<div id="deleteModalMessage"></div>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="modal-footer">
|
|
553
|
+
<button class="btn" data-action="close">Cancel</button>
|
|
554
|
+
<button class="btn btn-danger" id="deleteModalBtn">Delete</button>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
`;
|
|
558
|
+
|
|
559
|
+
document.body.appendChild(overlay);
|
|
560
|
+
|
|
561
|
+
const escHandler = (e) => {
|
|
562
|
+
if (e.key === 'Escape') {
|
|
563
|
+
overlay.remove();
|
|
564
|
+
document.removeEventListener('keydown', escHandler);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
overlay.addEventListener('click', (e) => {
|
|
569
|
+
if (e.target === overlay || e.target.dataset.action === 'close') {
|
|
570
|
+
overlay.remove();
|
|
571
|
+
document.removeEventListener('keydown', escHandler);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
document.addEventListener('keydown', escHandler);
|
|
576
|
+
|
|
577
|
+
overlay.querySelector('#deleteModalBtn').addEventListener('click', () => submitDeleteApp(overlay, appName));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function submitDeleteApp(overlay, appName) {
|
|
581
|
+
const msgEl = overlay.querySelector('#deleteModalMessage');
|
|
582
|
+
const deleteBtn = overlay.querySelector('#deleteModalBtn');
|
|
583
|
+
|
|
584
|
+
deleteBtn.disabled = true;
|
|
585
|
+
deleteBtn.textContent = 'Deleting...';
|
|
586
|
+
msgEl.innerHTML = '';
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const res = await fetch('/api/apps/' + encodeURIComponent(appName), { method: 'DELETE' });
|
|
590
|
+
const data = await res.json();
|
|
591
|
+
|
|
592
|
+
if (!res.ok) {
|
|
593
|
+
msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to delete app')}</div>`;
|
|
594
|
+
deleteBtn.disabled = false;
|
|
595
|
+
deleteBtn.textContent = 'Delete';
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
overlay.remove();
|
|
600
|
+
|
|
601
|
+
// Clean up client state
|
|
602
|
+
state.notificationsList = state.notificationsList.filter(n => n.app !== appName);
|
|
603
|
+
state.decisionsList = state.decisionsList.filter(d => d.app !== appName);
|
|
604
|
+
if (state.selectedApp && state.selectedApp.appName === appName) {
|
|
605
|
+
state.selectedApp = null;
|
|
606
|
+
state.selectedLoop = null;
|
|
607
|
+
document.title = 'RalphFlow Dashboard';
|
|
608
|
+
}
|
|
609
|
+
actions.fetchApps();
|
|
610
|
+
} catch (err) {
|
|
611
|
+
msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
|
|
612
|
+
deleteBtn.disabled = false;
|
|
613
|
+
deleteBtn.textContent = 'Delete';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function openArchiveAppModal(appName) {
|
|
618
|
+
const existing = document.querySelector('.modal-overlay');
|
|
619
|
+
if (existing) existing.remove();
|
|
620
|
+
|
|
621
|
+
const overlay = document.createElement('div');
|
|
622
|
+
overlay.className = 'modal-overlay';
|
|
623
|
+
overlay.innerHTML = `
|
|
624
|
+
<div class="modal">
|
|
625
|
+
<div class="modal-header">
|
|
626
|
+
<h3>Archive App</h3>
|
|
627
|
+
<button class="modal-close" data-action="close">×</button>
|
|
628
|
+
</div>
|
|
629
|
+
<div class="modal-body" id="archiveModalBody">
|
|
630
|
+
<p style="margin-bottom:12px">Archive <strong>${esc(appName)}</strong>?</p>
|
|
631
|
+
<p style="color:var(--text-dim);font-size:13px;margin-bottom:8px">This will snapshot all current work and reset to a clean slate:</p>
|
|
632
|
+
<ul style="color:var(--text-dim);font-size:13px;margin-left:18px;margin-bottom:12px;line-height:1.6">
|
|
633
|
+
<li>Stories, tasks, and trackers saved to <code style="font-family:var(--mono);font-size:12px;color:var(--text)">.archives/</code></li>
|
|
634
|
+
<li>Tracker and data files reset to template defaults</li>
|
|
635
|
+
<li>Prompts and config preserved</li>
|
|
636
|
+
</ul>
|
|
637
|
+
<div id="archiveModalMessage"></div>
|
|
638
|
+
</div>
|
|
639
|
+
<div class="modal-footer" id="archiveModalFooter">
|
|
640
|
+
<button class="btn" data-action="close">Cancel</button>
|
|
641
|
+
<button class="btn btn-primary" id="archiveModalBtn">Archive</button>
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
`;
|
|
645
|
+
|
|
646
|
+
document.body.appendChild(overlay);
|
|
647
|
+
|
|
648
|
+
const escHandler = (e) => {
|
|
649
|
+
if (e.key === 'Escape') {
|
|
650
|
+
overlay.remove();
|
|
651
|
+
document.removeEventListener('keydown', escHandler);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
overlay.addEventListener('click', (e) => {
|
|
656
|
+
if (e.target === overlay || e.target.dataset.action === 'close') {
|
|
657
|
+
overlay.remove();
|
|
658
|
+
document.removeEventListener('keydown', escHandler);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
document.addEventListener('keydown', escHandler);
|
|
663
|
+
|
|
664
|
+
overlay.querySelector('#archiveModalBtn').addEventListener('click', () => submitArchiveApp(overlay, appName));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function submitArchiveApp(overlay, appName) {
|
|
668
|
+
const msgEl = overlay.querySelector('#archiveModalMessage');
|
|
669
|
+
const archiveBtn = overlay.querySelector('#archiveModalBtn');
|
|
670
|
+
|
|
671
|
+
archiveBtn.disabled = true;
|
|
672
|
+
archiveBtn.textContent = 'Archiving...';
|
|
673
|
+
msgEl.innerHTML = '';
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const res = await fetch('/api/apps/' + encodeURIComponent(appName) + '/archive', { method: 'POST' });
|
|
677
|
+
const data = await res.json();
|
|
678
|
+
|
|
679
|
+
if (!res.ok) {
|
|
680
|
+
msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to archive app')}</div>`;
|
|
681
|
+
archiveBtn.disabled = false;
|
|
682
|
+
archiveBtn.textContent = 'Archive';
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Show success state
|
|
687
|
+
const body = overlay.querySelector('#archiveModalBody');
|
|
688
|
+
const footer = overlay.querySelector('#archiveModalFooter');
|
|
689
|
+
|
|
690
|
+
body.innerHTML = `
|
|
691
|
+
<p style="color:var(--green);margin-bottom:12px">Archived successfully.</p>
|
|
692
|
+
<p style="font-size:13px;color:var(--text-dim)">Snapshot saved to <code style="font-family:var(--mono);font-size:12px;color:var(--text)">${esc(data.archivePath)}</code></p>
|
|
693
|
+
<p style="font-size:13px;color:var(--text-dim);margin-top:8px">Timestamp: <strong style="color:var(--text)">${esc(data.timestamp)}</strong></p>
|
|
694
|
+
`;
|
|
695
|
+
footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
|
|
696
|
+
|
|
697
|
+
// Clean up client state and refresh
|
|
698
|
+
state.notificationsList = state.notificationsList.filter(n => n.app !== appName);
|
|
699
|
+
state.decisionsList = state.decisionsList.filter(d => d.app !== appName);
|
|
700
|
+
actions.fetchApps();
|
|
701
|
+
} catch (err) {
|
|
702
|
+
msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
|
|
703
|
+
archiveBtn.disabled = false;
|
|
704
|
+
archiveBtn.textContent = 'Archive';
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export async function openCreateAppModal() {
|
|
709
|
+
// Remove any existing modal
|
|
710
|
+
const existing = document.querySelector('.modal-overlay');
|
|
711
|
+
if (existing) existing.remove();
|
|
712
|
+
|
|
713
|
+
// Fetch available templates (built-in + custom)
|
|
714
|
+
let templates = [];
|
|
715
|
+
try {
|
|
716
|
+
templates = await fetchJson('/api/templates');
|
|
717
|
+
} catch {
|
|
718
|
+
templates = [
|
|
719
|
+
{ name: 'code-implementation', type: 'built-in' },
|
|
720
|
+
{ name: 'research', type: 'built-in' }
|
|
721
|
+
];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
let optionsHtml = '';
|
|
725
|
+
for (const tpl of templates) {
|
|
726
|
+
optionsHtml += `<option value="${esc(tpl.name)}">${esc(tpl.name)}${tpl.type === 'custom' ? ' (custom)' : ''}</option>`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const overlay = document.createElement('div');
|
|
730
|
+
overlay.className = 'modal-overlay';
|
|
731
|
+
overlay.innerHTML = `
|
|
732
|
+
<div class="modal">
|
|
733
|
+
<div class="modal-header">
|
|
734
|
+
<h3>Create New App</h3>
|
|
735
|
+
<button class="modal-close" data-action="close">×</button>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="modal-body" id="modalBody">
|
|
738
|
+
<div class="form-group">
|
|
739
|
+
<label class="form-label">Template</label>
|
|
740
|
+
<select class="form-select" id="modalTemplate">
|
|
741
|
+
${optionsHtml}
|
|
742
|
+
</select>
|
|
743
|
+
</div>
|
|
744
|
+
<div class="form-group">
|
|
745
|
+
<label class="form-label">App Name</label>
|
|
746
|
+
<input class="form-input" id="modalName" type="text" placeholder="my-feature" autocomplete="off">
|
|
747
|
+
</div>
|
|
748
|
+
<div id="modalMessage"></div>
|
|
749
|
+
</div>
|
|
750
|
+
<div class="modal-footer" id="modalFooter">
|
|
751
|
+
<button class="btn" data-action="close">Cancel</button>
|
|
752
|
+
<button class="btn btn-primary" id="modalCreateBtn">Create</button>
|
|
753
|
+
</div>
|
|
754
|
+
</div>
|
|
755
|
+
`;
|
|
756
|
+
|
|
757
|
+
document.body.appendChild(overlay);
|
|
758
|
+
|
|
759
|
+
// Close on overlay click or close buttons
|
|
760
|
+
overlay.addEventListener('click', (e) => {
|
|
761
|
+
if (e.target === overlay || e.target.dataset.action === 'close') {
|
|
762
|
+
overlay.remove();
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Close on Escape
|
|
767
|
+
const escHandler = (e) => {
|
|
768
|
+
if (e.key === 'Escape') {
|
|
769
|
+
overlay.remove();
|
|
770
|
+
document.removeEventListener('keydown', escHandler);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
document.addEventListener('keydown', escHandler);
|
|
774
|
+
|
|
775
|
+
// Focus name input
|
|
776
|
+
const nameInput = overlay.querySelector('#modalName');
|
|
777
|
+
setTimeout(() => nameInput.focus(), 50);
|
|
778
|
+
|
|
779
|
+
// Submit on Enter in name input
|
|
780
|
+
nameInput.addEventListener('keydown', (e) => {
|
|
781
|
+
if (e.key === 'Enter') {
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
submitCreateApp(overlay);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Create button
|
|
788
|
+
const createBtn = overlay.querySelector('#modalCreateBtn');
|
|
789
|
+
createBtn.addEventListener('click', () => submitCreateApp(overlay));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function submitCreateApp(overlay) {
|
|
793
|
+
const templateEl = overlay.querySelector('#modalTemplate');
|
|
794
|
+
const nameEl = overlay.querySelector('#modalName');
|
|
795
|
+
const msgEl = overlay.querySelector('#modalMessage');
|
|
796
|
+
const createBtn = overlay.querySelector('#modalCreateBtn');
|
|
797
|
+
|
|
798
|
+
const template = templateEl.value;
|
|
799
|
+
const name = nameEl.value.trim();
|
|
800
|
+
|
|
801
|
+
// Client-side validation
|
|
802
|
+
if (!name) {
|
|
803
|
+
msgEl.innerHTML = '<div class="form-error">Name is required</div>';
|
|
804
|
+
nameEl.focus();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Disable button during request
|
|
809
|
+
createBtn.disabled = true;
|
|
810
|
+
createBtn.textContent = 'Creating...';
|
|
811
|
+
msgEl.innerHTML = '';
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const res = await fetch('/api/apps', {
|
|
815
|
+
method: 'POST',
|
|
816
|
+
headers: { 'Content-Type': 'application/json' },
|
|
817
|
+
body: JSON.stringify({ template, name }),
|
|
818
|
+
});
|
|
819
|
+
const data = await res.json();
|
|
820
|
+
|
|
821
|
+
if (!res.ok) {
|
|
822
|
+
msgEl.innerHTML = `<div class="form-error">${esc(data.error || 'Failed to create app')}</div>`;
|
|
823
|
+
createBtn.disabled = false;
|
|
824
|
+
createBtn.textContent = 'Create';
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Success — show next-steps view
|
|
829
|
+
showNextSteps(overlay, data);
|
|
830
|
+
} catch (err) {
|
|
831
|
+
msgEl.innerHTML = '<div class="form-error">Network error — could not reach server</div>';
|
|
832
|
+
createBtn.disabled = false;
|
|
833
|
+
createBtn.textContent = 'Create';
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function showNextSteps(overlay, data) {
|
|
838
|
+
const body = overlay.querySelector('#modalBody');
|
|
839
|
+
const footer = overlay.querySelector('#modalFooter');
|
|
840
|
+
|
|
841
|
+
let warningHtml = '';
|
|
842
|
+
if (data.warning) {
|
|
843
|
+
warningHtml = `<div class="form-warning">${esc(data.warning)}</div>`;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let cmdsHtml = '';
|
|
847
|
+
for (const cmd of data.commands) {
|
|
848
|
+
cmdsHtml += `
|
|
849
|
+
<div class="cmd-item">
|
|
850
|
+
<span class="cmd-text">${esc(cmd)}</span>
|
|
851
|
+
<button class="cmd-copy" data-cmd="${esc(cmd)}">Copy</button>
|
|
852
|
+
</div>`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
body.innerHTML = `
|
|
856
|
+
<div class="next-steps-success">✓ Created ${esc(data.appName)}</div>
|
|
857
|
+
${warningHtml}
|
|
858
|
+
<div class="next-steps-label">Next steps — run one of these in your terminal:</div>
|
|
859
|
+
${cmdsHtml}
|
|
860
|
+
`;
|
|
861
|
+
|
|
862
|
+
footer.innerHTML = `<button class="btn btn-primary" data-action="close">Done</button>`;
|
|
863
|
+
footer.querySelector('[data-action="close"]').addEventListener('click', () => overlay.remove());
|
|
864
|
+
|
|
865
|
+
// Copy-to-clipboard buttons
|
|
866
|
+
body.querySelectorAll('.cmd-copy').forEach((btn) => {
|
|
867
|
+
btn.addEventListener('click', () => {
|
|
868
|
+
const cmd = btn.dataset.cmd || '';
|
|
869
|
+
navigator.clipboard.writeText(cmd).then(() => {
|
|
870
|
+
const orig = btn.textContent;
|
|
871
|
+
btn.textContent = 'Copied!';
|
|
872
|
+
setTimeout(() => { btn.textContent = orig; }, 1500);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Expose modal functions globally for inline onclick handlers
|
|
879
|
+
window.openDeleteAppModal = openDeleteAppModal;
|
|
880
|
+
window.openArchiveAppModal = openArchiveAppModal;
|