server-studio 1.0.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/src/index.html ADDED
@@ -0,0 +1,549 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Server Studio</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0c0d10;
10
+ --bg-soft: #131419;
11
+ --card: #16181f;
12
+ --card-hover: #1b1e27;
13
+ --border: #24262f;
14
+ --border-soft: #1e2028;
15
+ --text: #e7e9ee;
16
+ --text-dim: #9aa0ad;
17
+ --text-faint: #636978;
18
+ --accent: #6d8bff;
19
+ --accent-soft: rgba(109, 139, 255, 0.12);
20
+ --green: #3ecf8e;
21
+ --green-soft: rgba(62, 207, 142, 0.14);
22
+ --red: #f0556a;
23
+ --red-soft: rgba(240, 85, 106, 0.14);
24
+ --amber: #f5b343;
25
+ --radius: 14px;
26
+ --shadow: 0 8px 30px rgba(0,0,0,0.4);
27
+ font-synthesis: none;
28
+ }
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body {
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ -webkit-font-smoothing: antialiased;
35
+ min-height: 100vh;
36
+ padding: 32px 24px 80px;
37
+ background-image: radial-gradient(900px 500px at 80% -10%, rgba(109,139,255,0.06), transparent 60%);
38
+ }
39
+ .wrap { max-width: 1180px; margin: 0 auto; }
40
+ header { display: flex; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 28px; }
41
+ .title h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
42
+ .title .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); }
43
+ .title p { color: var(--text-dim); font-size: 13.5px; margin-top: 6px; }
44
+ .head-actions { display: flex; gap: 10px; flex-wrap: wrap; }
45
+
46
+ button, .btn {
47
+ font-family: inherit; font-size: 13.5px; font-weight: 550;
48
+ border: 1px solid var(--border); background: var(--card); color: var(--text);
49
+ padding: 9px 15px; border-radius: 10px; cursor: pointer; transition: all 0.15s ease;
50
+ display: inline-flex; align-items: center; gap: 7px; line-height: 1; text-decoration: none;
51
+ }
52
+ button:hover, .btn:hover { background: var(--card-hover); border-color: #313440; }
53
+ button:active { transform: translateY(1px); }
54
+ .btn-primary { background: var(--accent); border-color: var(--accent); color: #0b0d12; font-weight: 650; }
55
+ .btn-primary:hover { background: #7e98ff; border-color: #7e98ff; }
56
+ .btn-run { background: var(--green); border-color: var(--green); color: #05140d; font-weight: 650; }
57
+ .btn-run:hover { background: #4fdc9b; border-color: #4fdc9b; }
58
+ .btn-ghost { background: transparent; }
59
+
60
+ .toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 22px; flex-wrap: wrap; }
61
+ .search { flex: 1; min-width: 220px; position: relative; }
62
+ .search input {
63
+ width: 100%; background: var(--bg-soft); border: 1px solid var(--border); color: var(--text);
64
+ padding: 11px 14px 11px 38px; border-radius: 11px; font-size: 14px; font-family: inherit; outline: none; transition: border-color 0.15s;
65
+ }
66
+ .search input:focus { border-color: var(--accent); }
67
+ .search > svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-faint); }
68
+ .stats { display: flex; gap: 18px; color: var(--text-dim); font-size: 13px; padding-right: 4px; }
69
+ .stats b { color: var(--text); font-weight: 600; }
70
+
71
+ .filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 22px; }
72
+ .chip {
73
+ font-size: 12.5px; font-weight: 550; padding: 7px 13px; border-radius: 999px;
74
+ border: 1px solid var(--border); background: var(--bg-soft); color: var(--text-dim);
75
+ cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px;
76
+ }
77
+ .chip:hover { border-color: #313440; color: var(--text); }
78
+ .chip.active { background: var(--accent); border-color: var(--accent); color: #0b0d12; font-weight: 650; }
79
+ .chip .n { opacity: 0.65; font-weight: 600; }
80
+ .chip.active .n { opacity: 0.8; }
81
+
82
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; }
83
+ .card {
84
+ background: var(--card); border: 1px solid var(--border-soft); border-radius: var(--radius);
85
+ padding: 18px; display: flex; flex-direction: column; gap: 13px; transition: border-color 0.15s, transform 0.15s; position: relative;
86
+ }
87
+ .card:hover { border-color: #2e313d; transform: translateY(-2px); }
88
+ .card.pinned { border-color: rgba(245,179,67,0.35); }
89
+ .card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
90
+ .card-name { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; }
91
+ .pin-star { cursor: pointer; color: var(--text-faint); font-size: 15px; line-height: 1; background: none; border: none; padding: 2px; }
92
+ .pin-star.on { color: var(--amber); }
93
+ .badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; justify-content: flex-end; }
94
+ .tag { font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
95
+ .cat { font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 999px; background: rgba(245,179,67,0.13); color: var(--amber); white-space: nowrap; }
96
+ .project { font-size: 13px; color: var(--text-dim); display: flex; align-items: center; gap: 7px; }
97
+ .project svg { color: var(--text-faint); flex-shrink: 0; }
98
+ .url-row {
99
+ display: flex; align-items: center; gap: 8px; background: var(--bg-soft); border: 1px solid var(--border-soft);
100
+ border-radius: 9px; padding: 8px 10px; font-size: 12.5px; font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--text-dim);
101
+ }
102
+ .status { width: 8px; height: 8px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: all 0.2s; }
103
+ .status.up { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); }
104
+ .status.down { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
105
+ .status.checking { background: var(--amber); animation: pulse 1s infinite; }
106
+ @keyframes pulse { 50% { opacity: 0.35; } }
107
+ .url-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
108
+ .cmd-line { font-size: 11.5px; font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--text-faint); display: flex; align-items: center; gap: 6px; }
109
+ .cmd-line svg { flex-shrink: 0; opacity: 0.7; }
110
+ .cmd-line span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
111
+ .note { font-size: 13px; color: var(--text-faint); line-height: 1.5; }
112
+ .card-actions { display: flex; gap: 8px; margin-top: 2px; }
113
+ .card-actions .main { flex: 1; justify-content: center; }
114
+ .icon-btn { padding: 9px; }
115
+
116
+ .empty { text-align: center; padding: 80px 20px; color: var(--text-faint); border: 1.5px dashed var(--border); border-radius: var(--radius); }
117
+ .empty h3 { color: var(--text-dim); font-size: 16px; font-weight: 600; margin-bottom: 8px; }
118
+ .empty p { font-size: 13.5px; margin-bottom: 20px; }
119
+
120
+ .overlay { position: fixed; inset: 0; background: rgba(5,6,8,0.7); backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; padding: 24px; z-index: 50; overflow-y: auto; }
121
+ .overlay.open { display: flex; }
122
+ .modal { background: var(--card); border: 1px solid var(--border); border-radius: 16px; width: 100%; max-width: 480px; padding: 24px; box-shadow: var(--shadow); margin: auto; }
123
+ .modal h2 { font-size: 18px; font-weight: 650; margin-bottom: 4px; letter-spacing: -0.01em; }
124
+ .modal .sub { color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }
125
+ .field { margin-bottom: 14px; }
126
+ .field label { display: block; font-size: 12.5px; color: var(--text-dim); margin-bottom: 6px; font-weight: 500; }
127
+ .field input, .field textarea {
128
+ width: 100%; background: var(--bg-soft); border: 1px solid var(--border); color: var(--text);
129
+ padding: 10px 12px; border-radius: 9px; font-size: 14px; font-family: inherit; outline: none; transition: border-color 0.15s;
130
+ }
131
+ .field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; }
132
+ .field input:focus, .field textarea:focus { border-color: var(--accent); }
133
+ .field textarea { resize: vertical; min-height: 52px; }
134
+ .field .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 5px; }
135
+ .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
136
+ .folder-row { display: flex; gap: 8px; }
137
+ .folder-row input { flex: 1; }
138
+ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
139
+
140
+ .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px); background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 12px 18px; border-radius: 11px; font-size: 13.5px; box-shadow: var(--shadow); opacity: 0; transition: all 0.25s; z-index: 80; display: flex; align-items: center; gap: 9px; }
141
+ .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
142
+ .toast .tdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
143
+ .toast.err .tdot { background: var(--red); }
144
+
145
+ @media (max-width: 560px) { body { padding: 20px 14px 60px; } .row2 { grid-template-columns: 1fr; } }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <div class="wrap">
150
+ <header>
151
+ <div class="title">
152
+ <h1><span class="dot"></span> Server Studio</h1>
153
+ <p>All your local servers in one place. One click to run.</p>
154
+ </div>
155
+ <div class="head-actions">
156
+ <button class="btn-ghost" id="checkAll" title="Re-check which servers are online">
157
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.2-8.5"/><polyline points="21 3 21 9 15 9"/></svg>
158
+ Refresh status
159
+ </button>
160
+ <button class="btn-ghost" id="exportBtn" title="Save a backup file">Export</button>
161
+ <button class="btn-ghost" id="importBtn" title="Load from backup file">Import</button>
162
+ <input type="file" id="importFile" accept=".json" hidden>
163
+ <button class="btn-primary" id="addBtn">
164
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
165
+ Add server
166
+ </button>
167
+ </div>
168
+ </header>
169
+
170
+ <div class="toolbar">
171
+ <div class="search">
172
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
173
+ <input type="text" id="search" placeholder="Search by name, project, URL, command or tag...">
174
+ </div>
175
+ <div class="stats">
176
+ <span><b id="countTotal">0</b> servers</span>
177
+ <span><b id="countUp">0</b> online</span>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="filters" id="filters"></div>
182
+ <div class="grid" id="grid"></div>
183
+
184
+ <div class="empty" id="empty" style="display:none;">
185
+ <h3>No servers yet</h3>
186
+ <p>Add your first local server so you always know which command runs which project.</p>
187
+ <button class="btn-primary" onclick="openModal()">+ Add your first server</button>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="overlay" id="overlay">
192
+ <div class="modal">
193
+ <h2 id="modalTitle">Add server</h2>
194
+ <div class="sub">Give it a name and the command to start it.</div>
195
+ <input type="hidden" id="editId">
196
+ <div class="field">
197
+ <label>Name</label>
198
+ <input type="text" id="f_name" placeholder="e.g. Portfolio Site">
199
+ </div>
200
+ <div class="row2">
201
+ <div class="field">
202
+ <label>Project</label>
203
+ <input type="text" id="f_project" placeholder="e.g. Client redesign">
204
+ </div>
205
+ <div class="field">
206
+ <label>Category</label>
207
+ <input type="text" id="f_category" list="catList" placeholder="WordPress, Web app...">
208
+ <datalist id="catList"></datalist>
209
+ </div>
210
+ </div>
211
+ <div class="field">
212
+ <label>Project folder</label>
213
+ <div class="folder-row">
214
+ <input type="text" id="f_cwd" class="mono" placeholder="/Users/you/Sites/project">
215
+ <button type="button" class="btn-ghost" id="browseBtn">Browse</button>
216
+ </div>
217
+ <div class="hint">Where the run command should execute</div>
218
+ </div>
219
+ <div class="field">
220
+ <label>Run command</label>
221
+ <input type="text" id="f_command" class="mono" placeholder="npm run dev">
222
+ <div class="hint">Runs in a new <span class="term-name">terminal</span> window. Leave empty if there's nothing to start.</div>
223
+ </div>
224
+ <div class="row2">
225
+ <div class="field">
226
+ <label>URL / Address</label>
227
+ <input type="text" id="f_url" class="mono" placeholder="localhost:3000">
228
+ <div class="hint">Port alone works too</div>
229
+ </div>
230
+ <div class="field">
231
+ <label>Tag</label>
232
+ <input type="text" id="f_tag" placeholder="Vite, MAMP...">
233
+ </div>
234
+ </div>
235
+ <div class="field">
236
+ <label>Notes <span style="color:var(--text-faint)">(optional)</span></label>
237
+ <textarea id="f_note" placeholder="Login, what it's for, anything to remember..."></textarea>
238
+ </div>
239
+ <div class="modal-actions">
240
+ <button class="btn-ghost" onclick="closeModal()">Cancel</button>
241
+ <button class="btn-primary" id="saveBtn">Save server</button>
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ <div class="toast" id="toast"><span class="tdot"></span><span id="toastMsg"></span></div>
247
+
248
+ <script>
249
+ let servers = [];
250
+ let activeCat = 'all';
251
+
252
+ /* ---------- api ---------- */
253
+ async function api(path, opts) {
254
+ const r = await fetch(path, opts);
255
+ return r.json();
256
+ }
257
+ let TERM_NAME = 'your terminal';
258
+ async function loadPlatform() {
259
+ try {
260
+ const p = await api('/api/platform');
261
+ if (p && p.terminalName) {
262
+ TERM_NAME = p.terminalName;
263
+ document.querySelectorAll('.term-name').forEach(el => { el.textContent = TERM_NAME; });
264
+ const cwdInput = document.getElementById('f_cwd');
265
+ if (cwdInput && p.pathExample) cwdInput.placeholder = p.pathExample;
266
+ }
267
+ } catch (e) { /* label just stays generic */ }
268
+ }
269
+ async function loadServers() {
270
+ try { servers = await api('/api/servers'); }
271
+ catch (e) { servers = []; }
272
+ if (!Array.isArray(servers)) servers = [];
273
+ }
274
+ let saveTimer = null;
275
+ let pendingSaves = 0;
276
+ function persist() {
277
+ clearTimeout(saveTimer);
278
+ saveTimer = setTimeout(() => {
279
+ const clean = servers.map(({ _status, ...rest }) => rest);
280
+ pendingSaves++;
281
+ fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(clean) })
282
+ .finally(() => { pendingSaves--; });
283
+ }, 150);
284
+ }
285
+
286
+ function stripIds(arr) { return JSON.stringify(arr.map(({ _status, ...r }) => r)); }
287
+
288
+ // Pull the latest list from disk so servers saved elsewhere (e.g. the save-server
289
+ // skill) show up without a manual reload. Skips while editing or mid-save to avoid clobbering.
290
+ async function syncList() {
291
+ if (pendingSaves > 0) return;
292
+ if (document.getElementById('overlay').classList.contains('open')) return;
293
+ let remote;
294
+ try { remote = await api('/api/servers'); } catch (e) { return; }
295
+ if (!Array.isArray(remote)) return;
296
+ if (stripIds(remote) === stripIds(servers)) return; // no change
297
+ const statusById = {};
298
+ servers.forEach(s => { statusById[s.id] = s._status; });
299
+ remote.forEach(s => { s._status = statusById[s.id]; });
300
+ servers = remote;
301
+ render();
302
+ refreshStatus();
303
+ }
304
+
305
+ function uid() { return 's_' + Math.random().toString(36).slice(2, 9) + Date.now().toString(36); }
306
+ function esc(s) { return (s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
307
+ function normalizeUrl(raw) {
308
+ let u = (raw || '').trim();
309
+ if (!u) return '';
310
+ if (/^\d+$/.test(u)) u = 'localhost:' + u;
311
+ if (!/^https?:\/\//i.test(u)) u = 'http://' + u;
312
+ return u;
313
+ }
314
+ function portOf(s) {
315
+ if (s.port) return s.port;
316
+ const m = String(s.url || '').match(/:(\d{2,5})\b/);
317
+ return m ? Number(m[1]) : null;
318
+ }
319
+
320
+ function toast(msg, isErr) {
321
+ const t = document.getElementById('toast');
322
+ document.getElementById('toastMsg').textContent = msg;
323
+ t.classList.toggle('err', !!isErr);
324
+ t.classList.add('show');
325
+ clearTimeout(toast._t);
326
+ toast._t = setTimeout(() => t.classList.remove('show'), 2600);
327
+ }
328
+
329
+ /* ---------- categories / filters ---------- */
330
+ function categories() {
331
+ return [...new Set(servers.map(s => (s.category || '').trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
332
+ }
333
+ function renderFilters() {
334
+ const cats = categories();
335
+ const box = document.getElementById('filters');
336
+ if (cats.length === 0) { box.innerHTML = ''; return; }
337
+ const count = c => servers.filter(s => (s.category || '').trim() === c).length;
338
+ const chip = (key, label, n) =>
339
+ `<button class="chip ${activeCat === key ? 'active' : ''}" onclick="setCat('${key.replace(/'/g, "\\'")}')">${esc(label)} <span class="n">${n}</span></button>`;
340
+ box.innerHTML = chip('all', 'All', servers.length) + cats.map(c => chip(c, c, count(c))).join('');
341
+ }
342
+ function setCat(c) { activeCat = c; render(); }
343
+ function refreshCatList() {
344
+ document.getElementById('catList').innerHTML = categories().map(c => `<option value="${esc(c)}">`).join('');
345
+ }
346
+
347
+ /* ---------- render ---------- */
348
+ function render() {
349
+ const q = document.getElementById('search').value.toLowerCase().trim();
350
+ const grid = document.getElementById('grid');
351
+ const empty = document.getElementById('empty');
352
+
353
+ if (activeCat !== 'all' && !categories().includes(activeCat)) activeCat = 'all';
354
+ renderFilters();
355
+ refreshCatList();
356
+
357
+ let list = servers.filter(s =>
358
+ (activeCat === 'all' || (s.category || '').trim() === activeCat) &&
359
+ (!q || [s.name, s.project, s.url, s.tag, s.category, s.command, s.note].join(' ').toLowerCase().includes(q))
360
+ );
361
+ list.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
362
+
363
+ document.getElementById('countTotal').textContent = servers.length;
364
+ document.getElementById('countUp').textContent = servers.filter(s => s._status === 'up').length;
365
+
366
+ if (servers.length === 0) { grid.innerHTML = ''; empty.style.display = 'block'; return; }
367
+ empty.style.display = 'none';
368
+ if (list.length === 0) { grid.innerHTML = '<div class="empty" style="grid-column:1/-1;">No servers match.</div>'; return; }
369
+
370
+ grid.innerHTML = list.map(s => {
371
+ const url = normalizeUrl(s.url);
372
+ const st = s._status || 'unknown';
373
+ const port = portOf(s);
374
+ return `
375
+ <div class="card ${s.pinned ? 'pinned' : ''}">
376
+ <div class="card-top">
377
+ <div class="card-name">
378
+ <button class="pin-star ${s.pinned ? 'on' : ''}" onclick="togglePin('${s.id}')" title="Pin to top">${s.pinned ? '★' : '☆'}</button>
379
+ ${esc(s.name) || 'Untitled'}
380
+ </div>
381
+ <div class="badges">
382
+ ${s.category ? `<span class="cat">${esc(s.category)}</span>` : ''}
383
+ ${s.tag ? `<span class="tag">${esc(s.tag)}</span>` : ''}
384
+ </div>
385
+ </div>
386
+ ${s.project ? `<div class="project">
387
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
388
+ ${esc(s.project)}</div>` : ''}
389
+ ${url ? `<div class="url-row">
390
+ <span class="status ${st === 'unknown' ? '' : st}" title="${st}"></span>
391
+ <span class="url-text">${esc(url.replace(/^https?:\/\//, ''))}</span>
392
+ <button style="padding:2px 4px;border:none;background:none;color:var(--text-faint)" onclick="copyUrl('${url}', this)" title="Copy URL">
393
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
394
+ </button>
395
+ </div>` : ''}
396
+ ${s.command ? `<div class="cmd-line">
397
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
398
+ <span>${esc(s.command)}</span></div>` : ''}
399
+ ${s.note ? `<div class="note">${esc(s.note)}</div>` : ''}
400
+ <div class="card-actions">
401
+ ${s.command
402
+ ? `<button class="btn-run main" onclick="runServer('${s.id}')" title="Run">
403
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 4 20 12 6 20 6 4"/></svg> Run</button>`
404
+ : ''}
405
+ ${url
406
+ ? `<a class="btn ${s.command ? '' : 'btn-primary main'}" href="${url}" target="_blank" rel="noopener" title="Open in browser">
407
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>${s.command ? '' : ' Open'}</a>`
408
+ : ''}
409
+ ${port ? `<button class="icon-btn" onclick="stopServer('${s.id}')" title="Stop server on port ${port}">
410
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>` : ''}
411
+ <button class="icon-btn" onclick="editServer('${s.id}')" title="Edit">
412
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4z"/></svg></button>
413
+ <button class="icon-btn" onclick="delServer('${s.id}')" title="Delete">
414
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
415
+ </div>
416
+ </div>`;
417
+ }).join('');
418
+ }
419
+
420
+ /* ---------- actions ---------- */
421
+ async function runServer(id) {
422
+ const s = servers.find(x => x.id === id);
423
+ if (!s || !s.command) return;
424
+ const r = await api('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cwd: s.cwd || '', command: s.command }) });
425
+ if (r.ok) { toast('Running "' + (s.name || 'server') + '" in ' + TERM_NAME); setTimeout(() => refreshStatus(), 3500); }
426
+ else toast('Could not run: ' + (r.error || 'error'), true);
427
+ }
428
+ async function stopServer(id) {
429
+ const s = servers.find(x => x.id === id);
430
+ const port = portOf(s);
431
+ if (!port) return;
432
+ await api('/api/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port }) });
433
+ toast('Stopped whatever was on port ' + port);
434
+ setTimeout(() => refreshStatus(), 700);
435
+ }
436
+ function copyUrl(url, btn) {
437
+ navigator.clipboard.writeText(url).then(() => {
438
+ const old = btn.innerHTML;
439
+ btn.innerHTML = '<span style="color:var(--green);font-size:11px">copied</span>';
440
+ setTimeout(() => btn.innerHTML = old, 1200);
441
+ });
442
+ }
443
+
444
+ /* ---------- crud ---------- */
445
+ function openModal(id) {
446
+ document.getElementById('overlay').classList.add('open');
447
+ document.getElementById('modalTitle').textContent = id ? 'Edit server' : 'Add server';
448
+ document.getElementById('editId').value = id || '';
449
+ const s = servers.find(x => x.id === id) || {};
450
+ f_name.value = s.name || ''; f_project.value = s.project || ''; f_category.value = s.category || '';
451
+ f_cwd.value = s.cwd || ''; f_command.value = s.command || ''; f_url.value = s.url || '';
452
+ f_tag.value = s.tag || ''; f_note.value = s.note || '';
453
+ setTimeout(() => f_name.focus(), 50);
454
+ }
455
+ function closeModal() { document.getElementById('overlay').classList.remove('open'); }
456
+ function saveServer() {
457
+ const id = document.getElementById('editId').value;
458
+ const data = {
459
+ name: f_name.value.trim(), project: f_project.value.trim(), category: f_category.value.trim(),
460
+ cwd: f_cwd.value.trim(), command: f_command.value.trim(), url: f_url.value.trim(),
461
+ tag: f_tag.value.trim(), note: f_note.value.trim(),
462
+ };
463
+ if (!data.name && !data.url && !data.command) { toast('Add at least a name, URL or command.', true); return; }
464
+ if (id) { Object.assign(servers.find(x => x.id === id), data); }
465
+ else { servers.push({ id: uid(), pinned: false, ...data }); }
466
+ persist(); closeModal(); render(); refreshStatus();
467
+ }
468
+ function editServer(id) { openModal(id); }
469
+ function delServer(id) {
470
+ const s = servers.find(x => x.id === id);
471
+ if (confirm(`Delete "${s.name || 'this server'}"? This only removes it from the list.`)) {
472
+ servers = servers.filter(x => x.id !== id); persist(); render();
473
+ }
474
+ }
475
+ function togglePin(id) { const s = servers.find(x => x.id === id); s.pinned = !s.pinned; persist(); render(); }
476
+
477
+ async function browseFolder() {
478
+ const r = await api('/api/pickfolder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
479
+ if (r.path) f_cwd.value = r.path;
480
+ }
481
+
482
+ /* ---------- status ---------- */
483
+ async function refreshStatus() {
484
+ servers.forEach(s => { if (portOf(s)) s._status = 'checking'; });
485
+ render();
486
+ try {
487
+ const map = await api('/api/status');
488
+ servers.forEach(s => { s._status = map[s.id] ? 'up' : (portOf(s) ? 'down' : 'unknown'); });
489
+ } catch (e) {}
490
+ render();
491
+ }
492
+
493
+ /* ---------- export / import ---------- */
494
+ function exportData() {
495
+ const clean = servers.map(({ _status, ...rest }) => rest);
496
+ const blob = new Blob([JSON.stringify(clean, null, 2)], { type: 'application/json' });
497
+ const a = document.createElement('a');
498
+ a.href = URL.createObjectURL(blob); a.download = 'server-studio-backup.json'; a.click();
499
+ }
500
+ function importData(file) {
501
+ const reader = new FileReader();
502
+ reader.onload = e => {
503
+ try {
504
+ const data = JSON.parse(e.target.result);
505
+ if (!Array.isArray(data)) throw 0;
506
+ data.forEach(d => { if (!d.id) d.id = uid(); });
507
+ servers = data; persist(); render(); refreshStatus();
508
+ toast('Imported ' + data.length + ' servers');
509
+ } catch { toast('That file is not a valid backup.', true); }
510
+ };
511
+ reader.readAsText(file);
512
+ }
513
+
514
+ /* ---------- one-time migration from old browser version ---------- */
515
+ async function migrateFromLocalStorage() {
516
+ if (servers.length) return;
517
+ try {
518
+ const old = JSON.parse(localStorage.getItem('local_server_dashboard_v1') || '[]');
519
+ if (Array.isArray(old) && old.length) { servers = old; persist(); toast('Imported your previous list'); }
520
+ } catch (e) {}
521
+ }
522
+
523
+ /* ---------- wire up ---------- */
524
+ document.getElementById('addBtn').onclick = () => openModal();
525
+ document.getElementById('saveBtn').onclick = saveServer;
526
+ document.getElementById('browseBtn').onclick = browseFolder;
527
+ document.getElementById('search').oninput = render;
528
+ document.getElementById('checkAll').onclick = refreshStatus;
529
+ document.getElementById('exportBtn').onclick = exportData;
530
+ document.getElementById('importBtn').onclick = () => document.getElementById('importFile').click();
531
+ document.getElementById('importFile').onchange = e => { if (e.target.files[0]) importData(e.target.files[0]); };
532
+ document.getElementById('overlay').onclick = e => { if (e.target.id === 'overlay') closeModal(); };
533
+ document.addEventListener('keydown', e => {
534
+ if (e.key === 'Escape') closeModal();
535
+ if (e.key === 'Enter' && document.getElementById('overlay').classList.contains('open') && e.target.tagName !== 'TEXTAREA') saveServer();
536
+ });
537
+
538
+ (async function init() {
539
+ await loadPlatform();
540
+ await loadServers();
541
+ await migrateFromLocalStorage();
542
+ render();
543
+ refreshStatus();
544
+ setInterval(refreshStatus, 12000);
545
+ setInterval(syncList, 4000);
546
+ })();
547
+ </script>
548
+ </body>
549
+ </html>
@@ -0,0 +1,114 @@
1
+ // Per-OS shims. Everything the dashboard does that is not plain Node lives here.
2
+ 'use strict';
3
+
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execFile, exec } = require('child_process');
7
+
8
+ const WIN = process.platform === 'win32';
9
+ const MAC = process.platform === 'darwin';
10
+
11
+ /* ---------- quoting ---------- */
12
+ function shQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
13
+ function osaQuote(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
14
+
15
+ /* ---------- where the server list is stored ---------- */
16
+ function dataDir() {
17
+ if (process.env.SERVER_STUDIO_DATA_DIR) return process.env.SERVER_STUDIO_DATA_DIR;
18
+ if (MAC) return path.join(os.homedir(), 'Library', 'Application Support', 'Server Studio');
19
+ if (WIN) return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Server Studio');
20
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'server-studio');
21
+ }
22
+
23
+ /* ---------- open a URL or a folder in the desktop's default handler ---------- */
24
+ function openExternal(target, cb) {
25
+ if (MAC) return execFile('open', [target], cb);
26
+ // The empty string is start's window-title argument; without it a quoted
27
+ // target would be swallowed as the title.
28
+ if (WIN) return execFile('cmd', ['/c', 'start', '', target], cb);
29
+ return execFile('xdg-open', [target], cb);
30
+ }
31
+
32
+ function revealFolder(p, cb) {
33
+ if (MAC) return execFile('open', [p], cb);
34
+ if (WIN) return execFile('explorer', [p], () => cb(null)); // explorer exits non-zero even on success
35
+ return execFile('xdg-open', [p], cb);
36
+ }
37
+
38
+ /* ---------- run a command in a visible terminal window ---------- */
39
+ function runInTerminal(cwd, command, cb) {
40
+ if (MAC) {
41
+ let line = command;
42
+ if (cwd) line = 'cd ' + shQuote(cwd) + ' && ' + command;
43
+ return execFile('osascript', [
44
+ '-e', 'tell application "Terminal" to do script ' + osaQuote(line),
45
+ '-e', 'tell application "Terminal" to activate',
46
+ ], cb);
47
+ }
48
+ if (WIN) {
49
+ // /k keeps the window open so the dev server keeps running and its output stays readable.
50
+ const line = (cwd ? 'cd /d "' + cwd + '" && ' : '') + command;
51
+ return execFile('cmd', ['/c', 'start', '', 'cmd', '/k', line], cb);
52
+ }
53
+ // Linux has no standard terminal, so try the common ones in turn.
54
+ const line = (cwd ? 'cd ' + shQuote(cwd) + ' && ' : '') + command + '; exec $SHELL';
55
+ const candidates = [
56
+ ['x-terminal-emulator', ['-e', 'bash', '-lc', line]],
57
+ ['gnome-terminal', ['--', 'bash', '-lc', line]],
58
+ ['konsole', ['-e', 'bash', '-lc', line]],
59
+ ['xfce4-terminal', ['-e', 'bash -lc ' + shQuote(line)]],
60
+ ['xterm', ['-e', 'bash', '-lc', line]],
61
+ ];
62
+ (function tryNext(i) {
63
+ if (i >= candidates.length) {
64
+ return cb(new Error('No terminal emulator found. Install xterm, or run the command yourself.'));
65
+ }
66
+ execFile(candidates[i][0], candidates[i][1], err => (err ? tryNext(i + 1) : cb(null)));
67
+ })(0);
68
+ }
69
+
70
+ /* ---------- kill whatever holds a TCP port ---------- */
71
+ function killPort(port, cb) {
72
+ const p = Number(port);
73
+ if (!p) return cb(new Error('bad port'));
74
+ if (WIN) {
75
+ // netstat lists the owning PID in the last column of LISTENING rows.
76
+ return exec('netstat -ano -p tcp | findstr LISTENING | findstr :' + p, (err, stdout) => {
77
+ const pids = new Set(String(stdout || '').trim().split(/\r?\n/)
78
+ .map(l => l.trim().split(/\s+/).pop())
79
+ .filter(x => /^\d+$/.test(x) && x !== '0'));
80
+ if (!pids.size) return cb(null);
81
+ exec([...pids].map(id => 'taskkill /PID ' + id + ' /T /F').join(' & '), () => cb(null));
82
+ });
83
+ }
84
+ return exec('lsof -nti tcp:' + p + ' | xargs kill 2>/dev/null', () => cb(null));
85
+ }
86
+
87
+ /* ---------- native "choose a folder" dialog ---------- */
88
+ function pickFolder(cb) {
89
+ if (MAC) {
90
+ return execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "Pick the project folder")'],
91
+ (err, stdout) => (err ? cb(null, null) : cb(null, String(stdout).trim())));
92
+ }
93
+ if (WIN) {
94
+ const ps = 'Add-Type -AssemblyName System.Windows.Forms;' +
95
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog;' +
96
+ 'if ($d.ShowDialog() -eq "OK") { $d.SelectedPath }';
97
+ return execFile('powershell', ['-NoProfile', '-STA', '-Command', ps],
98
+ (err, stdout) => (err ? cb(null, null) : cb(null, String(stdout).trim() || null)));
99
+ }
100
+ return execFile('zenity', ['--file-selection', '--directory'],
101
+ (err, stdout) => (err ? cb(null, null) : cb(null, String(stdout).trim() || null)));
102
+ }
103
+
104
+ /* ---------- what the UI should call the terminal ---------- */
105
+ function terminalName() { return WIN ? 'Command Prompt' : MAC ? 'Terminal' : 'your terminal'; }
106
+
107
+ // Placeholder text for the project folder field, so the example looks native.
108
+ function pathExample() {
109
+ if (WIN) return 'C:\\Users\\you\\Projects\\app';
110
+ if (MAC) return '/Users/you/Sites/project';
111
+ return '/home/you/projects/app';
112
+ }
113
+
114
+ module.exports = { dataDir, openExternal, revealFolder, runInTerminal, killPort, pickFolder, terminalName, pathExample, WIN, MAC };