server-studio 1.1.0 → 1.2.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 CHANGED
@@ -37,6 +37,156 @@
37
37
  background-image: radial-gradient(900px 500px at 80% -10%, rgba(109,139,255,0.06), transparent 60%);
38
38
  }
39
39
  .wrap { max-width: 1180px; margin: 0 auto; }
40
+
41
+ /* ---------- shell: sidebar + main ---------- */
42
+ .shell { display: grid; grid-template-columns: 236px minmax(0,1fr); gap: 30px; align-items: start; }
43
+ .sidebar { position: sticky; top: 32px; display: flex; flex-direction: column; gap: 18px; height: calc(100vh - 64px); max-height: calc(100vh - 64px); }
44
+ .side-brand h1 { font-size: 19px; letter-spacing: -0.2px; }
45
+ .side-brand p { font-size: 12.5px; color: var(--text-faint); margin-top: 5px; }
46
+
47
+ .folders { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; flex: 0 1 auto; min-height: 0; margin: 0 -6px; padding: 0 6px; }
48
+ .folders::-webkit-scrollbar { width: 6px; }
49
+ .folders::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
50
+ .f-item {
51
+ display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-radius: 9px;
52
+ font-size: 13.5px; color: var(--text-dim); cursor: pointer; border: 1px solid transparent;
53
+ transition: background .13s, color .13s; user-select: none;
54
+ }
55
+ .f-item:hover { background: var(--bg-soft); color: var(--text); }
56
+ .f-item.active { background: var(--accent-soft); color: var(--accent); border-color: rgba(109,139,255,.22); }
57
+ .f-item.drop { border-color: var(--accent); background: var(--accent-soft); }
58
+ .f-item svg { flex: none; opacity: .8; }
59
+ .f-item .f-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
+ .f-item .n { font-size: 11.5px; color: var(--text-faint); font-variant-numeric: tabular-nums;
61
+ margin-left: auto; text-align: right; min-width: 18px; }
62
+ /* Always present so every row reserves the same width, otherwise the counts on
63
+ editable and non-editable rows land on different x positions. */
64
+ .f-item .f-edit { opacity: 0; border: 0; background: transparent; color: var(--text-faint);
65
+ cursor: pointer; padding: 2px; border-radius: 5px; display: flex; flex: none; }
66
+ .f-item .f-edit[disabled] { visibility: hidden; pointer-events: none; }
67
+ .f-item:hover .f-edit { opacity: 1; }
68
+ .f-item .f-edit:hover { color: var(--text); background: var(--border-soft); }
69
+ .f-sep { height: 1px; background: var(--border-soft); margin: 8px 4px; }
70
+ .f-new {
71
+ display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 9px; width: 100%;
72
+ background: transparent; border: 1px dashed var(--border); color: var(--text-faint);
73
+ font-size: 13px; cursor: pointer; font-family: inherit; transition: all .13s;
74
+ }
75
+ .f-new:hover { color: var(--text); border-color: var(--text-faint); }
76
+
77
+ /* ---------- ask box, docked bottom-left ---------- */
78
+ .ask { border: 1px solid var(--border); background: var(--bg-soft); border-radius: 12px; padding: 13px; position: relative; flex: none; }
79
+ .ask h4 { font-size: 12.5px; margin-bottom: 4px; letter-spacing: -0.1px; }
80
+ .ask p { font-size: 11.5px; color: var(--text-faint); line-height: 1.5; margin-bottom: 10px; }
81
+ .ask textarea, .ask input {
82
+ width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
83
+ color: var(--text); padding: 8px 9px; font: inherit; font-size: 12.5px; margin-bottom: 7px; resize: vertical;
84
+ }
85
+ .ask textarea { min-height: 58px; line-height: 1.5; font-family: inherit; }
86
+ .ask textarea:focus, .ask input:focus { outline: none; border-color: var(--accent); }
87
+ .ask button[type=submit] { width: 100%; justify-content: center; font-size: 12.5px; padding: 8px; }
88
+ .ask-x { position: absolute; top: 8px; right: 8px; border: 0; background: transparent; color: var(--text-faint); font-size: 17px; line-height: 1; cursor: pointer; padding: 2px 5px; border-radius: 6px; }
89
+ .ask-x:hover { color: var(--text); background: var(--border-soft); }
90
+ .ask-ok { font-size: 12px; color: var(--green); padding: 4px 0 2px; }
91
+
92
+ .help-btn {
93
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; border-radius: 9px;
94
+ background: var(--bg-soft); border: 1px solid var(--border); color: var(--text-dim);
95
+ font-size: 12.5px; cursor: pointer; font-family: inherit; transition: all .13s; flex: none;
96
+ }
97
+ .help-btn:hover { color: var(--text); border-color: var(--text-faint); }
98
+
99
+ /* ---------- version pill / update affordance ---------- */
100
+ /* Plain text while current, so its left edge matches the title and subtitle above it.
101
+ It only becomes a pill when there is something to act on. */
102
+ .ver {
103
+ display: inline-flex; align-items: center; gap: 6px; margin-top: 8px;
104
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px;
105
+ color: var(--text-faint); background: var(--bg-soft);
106
+ border: 1px solid var(--border); padding: 3px 9px; border-radius: 999px;
107
+ }
108
+ .ver.has-update {
109
+ color: var(--green); border-color: rgba(62,207,142,.4); background: var(--green-soft);
110
+ padding: 3px 9px;
111
+ cursor: pointer; animation: verPulse 2.4s ease-in-out 6;
112
+ }
113
+ .ver.has-update:hover { background: rgba(62,207,142,.24); }
114
+ .ver .dot-new { width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex: none; }
115
+ .ver.checking { opacity: .55; }
116
+ @keyframes verPulse {
117
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(62,207,142,0); }
118
+ 50% { box-shadow: 0 0 0 4px rgba(62,207,142,.18); }
119
+ }
120
+ @media (prefers-reduced-motion: reduce) { .ver.has-update { animation: none; } }
121
+
122
+ /* ---------- creator badge, sidebar bottom ---------- */
123
+ .credit { flex: none; margin-top: auto; }
124
+ .credit a {
125
+ display: flex; align-items: center; gap: 9px; padding: 9px 10px; border-radius: 10px;
126
+ border: 1px solid var(--border); background: var(--bg-soft);
127
+ text-decoration: none; transition: border-color .15s, background .15s, transform .15s;
128
+ }
129
+ .credit a:hover { border-color: var(--accent); background: var(--accent-soft); transform: translateY(-1px); }
130
+ }
131
+ .credit .who { min-width: 0; line-height: 1.25; }
132
+ .credit .who b { display: block; font-size: 12.5px; font-weight: 600; color: var(--text);
133
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
134
+ .credit .who span { display: block; font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint); }
135
+ .credit .go { margin-left: auto; flex: none; color: var(--text-faint); opacity: 0; transform: translateX(-3px); transition: all .15s; }
136
+ .credit a:hover .go { opacity: 1; transform: none; color: var(--accent); }
137
+
138
+ /* ---------- move-to-folder menu ---------- */
139
+ .menu {
140
+ position: fixed; z-index: 60; min-width: 190px; max-height: 300px; overflow-y: auto;
141
+ background: var(--card); border: 1px solid var(--border); border-radius: 11px;
142
+ padding: 5px; box-shadow: var(--shadow);
143
+ }
144
+ .menu-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--text-faint); padding: 6px 9px 4px; }
145
+ .menu button {
146
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
147
+ background: transparent; border: 0; color: var(--text-dim); font: inherit; font-size: 13px;
148
+ padding: 8px 9px; border-radius: 7px; cursor: pointer;
149
+ }
150
+ .menu button:hover { background: var(--bg-soft); color: var(--text); }
151
+ .menu button.on { color: var(--accent); }
152
+ .menu button .tick { margin-left: auto; opacity: 0; }
153
+ .menu button.on .tick { opacity: 1; }
154
+ .card.dragging { opacity: .45; }
155
+
156
+ /* ---------- little extras ---------- */
157
+ .conflict {
158
+ display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--amber);
159
+ background: rgba(245,179,67,.12); border: 1px solid rgba(245,179,67,.25);
160
+ border-radius: 6px; padding: 2px 7px; margin-left: 8px; vertical-align: middle;
161
+ font-family: inherit; cursor: pointer; transition: background .14s, border-color .14s;
162
+ }
163
+ .conflict:hover { background: rgba(245,179,67,.22); border-color: rgba(245,179,67,.5); }
164
+
165
+ @media (max-width: 900px) {
166
+ /* The sidebar dissolves so its children can be ordered around the server grid.
167
+ Nobody opening this on a phone wants an email form before their servers. */
168
+ .shell { grid-template-columns: 1fr; gap: 18px; }
169
+ .sidebar { display: contents; }
170
+ .side-brand { order: 1; }
171
+ .folders { order: 2; }
172
+ main.main { order: 3; min-width: 0; } /* element-scoped: .main alone also hits .btn-run.main */
173
+ .ask { order: 4; }
174
+ .help-btn { order: 5; }
175
+ .credit { order: 6; margin-top: 4px; }
176
+ .f-new { order: 7; }
177
+
178
+ .folders {
179
+ flex-direction: row; overflow-x: auto; flex-wrap: nowrap; min-width: 0;
180
+ gap: 6px; padding-bottom: 2px; scrollbar-width: none;
181
+ -webkit-overflow-scrolling: touch;
182
+ }
183
+ .folders::-webkit-scrollbar { display: none; }
184
+ .f-item { flex: none; }
185
+ .f-item .f-edit { display: none; }
186
+ .f-sep { display: none; }
187
+ .f-new { max-width: 220px; }
188
+ }
189
+
40
190
  header { display: flex; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 28px; }
41
191
  .title h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
42
192
  .title .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); }
@@ -55,6 +205,9 @@
55
205
  .btn-primary:hover { background: #7e98ff; border-color: #7e98ff; }
56
206
  .btn-run { background: var(--green); border-color: var(--green); color: #05140d; font-weight: 650; }
57
207
  .btn-run:hover { background: #4fdc9b; border-color: #4fdc9b; }
208
+ .btn-stop { background: var(--red); border-color: var(--red); color: #fff; font-weight: 650; }
209
+ .btn-stop:hover { background: #ff6a7d; border-color: #ff6a7d; }
210
+ .btn-busy { background: var(--bg-soft); border-color: var(--border); color: var(--text-faint); font-weight: 600; cursor: default; }
58
211
  .btn-ghost { background: transparent; }
59
212
 
60
213
  .toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 22px; flex-wrap: wrap; }
@@ -79,7 +232,7 @@
79
232
  .chip .n { opacity: 0.65; font-weight: 600; }
80
233
  .chip.active .n { opacity: 0.8; }
81
234
 
82
- .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 16px; }
235
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); gap: 16px; }
83
236
  .card {
84
237
  background: var(--card); border: 1px solid var(--border-soft); border-radius: var(--radius);
85
238
  padding: 18px; display: flex; flex-direction: column; gap: 13px; transition: border-color 0.15s, transform 0.15s; position: relative;
@@ -135,6 +288,17 @@
135
288
  .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
136
289
  .folder-row { display: flex; gap: 8px; }
137
290
  .folder-row input { flex: 1; }
291
+ .dlg { max-width: 420px; }
292
+ .dlg .body { color: var(--text-dim); font-size: 13.5px; line-height: 1.6; white-space: pre-line; margin-bottom: 18px; }
293
+ .dlg .body strong { color: var(--text); font-weight: 600; }
294
+ .dlg input {
295
+ width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 9px;
296
+ color: var(--text); padding: 10px 12px; font: inherit; font-size: 14px; margin-bottom: 4px;
297
+ }
298
+ .dlg input:focus { outline: none; border-color: var(--accent); }
299
+ .dlg .hint { color: var(--text-faint); font-size: 12px; margin-top: 8px; }
300
+ .btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
301
+ .btn-danger:hover { background: #ff6a7d; border-color: #ff6a7d; }
138
302
  .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
139
303
 
140
304
  .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; }
@@ -142,15 +306,84 @@
142
306
  .toast .tdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
143
307
  .toast.err .tdot { background: var(--red); }
144
308
 
145
- @media (max-width: 560px) { body { padding: 20px 14px 60px; } .row2 { grid-template-columns: 1fr; } }
309
+ .subscribe { margin-top: 28px; display: none; align-items: center; gap: 18px; flex-wrap: wrap;
310
+ padding: 16px 18px; border: 1px solid var(--border-soft); border-radius: var(--radius);
311
+ background: linear-gradient(180deg, var(--bg-soft), var(--card)); }
312
+ .subscribe.show { display: flex; }
313
+ .subscribe .sub-copy { flex: 1; min-width: 220px; }
314
+ .subscribe .sub-copy b { font-size: 14px; font-weight: 620; letter-spacing: -0.01em; }
315
+ .subscribe .sub-copy span { display: block; color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
316
+ .subscribe form { display: flex; gap: 8px; flex-wrap: wrap; }
317
+ .subscribe input { font-family: inherit; font-size: 13.5px; background: var(--bg); color: var(--text);
318
+ border: 1px solid var(--border); border-radius: 10px; padding: 9px 13px; min-width: 210px; outline: none; transition: border-color 0.15s; }
319
+ .subscribe input:focus { border-color: var(--accent); }
320
+ .subscribe .sub-x { background: transparent; border: none; color: var(--text-faint); cursor: pointer;
321
+ font-size: 20px; padding: 2px 6px; line-height: 1; align-self: flex-start; }
322
+ .subscribe .sub-x:hover { color: var(--text-dim); }
323
+
324
+ @media (max-width: 560px) {
325
+ body { padding: 20px 14px 60px; }
326
+ .row2 { grid-template-columns: 1fr; }
327
+ header { flex-direction: column; align-items: stretch; gap: 12px; }
328
+ .head-actions { flex-wrap: wrap; }
329
+ .head-actions .btn-ghost, .head-actions .btn-primary { flex: 1 1 auto; justify-content: center; }
330
+ .toolbar { flex-direction: column; align-items: stretch; gap: 10px; }
331
+ .search { width: 100%; }
332
+ .stats { justify-content: flex-start; gap: 14px; }
333
+ .card-actions { flex-wrap: wrap; }
334
+ }
146
335
  </style>
147
336
  </head>
148
337
  <body>
149
338
  <div class="wrap">
339
+ <div class="shell">
340
+
341
+ <!-- ---------------- sidebar ---------------- -->
342
+ <aside class="sidebar">
343
+ <div class="side-brand">
344
+ <h1>Server Studio</h1>
345
+ <p>All your local servers in one place.</p>
346
+ <span class="ver checking" id="verPill" title="Version">v…</span>
347
+ </div>
348
+
349
+ <nav class="folders" id="folders"></nav>
350
+
351
+ <button class="f-new" id="newFolder">
352
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
353
+ New folder
354
+ </button>
355
+
356
+ <!-- ask box, docked at the bottom. Collapses to the help button. -->
357
+ <div class="ask" id="ask">
358
+ <button class="ask-x" id="askDismiss" title="Hide" aria-label="Hide">&times;</button>
359
+ <h4>Ideas, problems, or just say hi</h4>
360
+ <p>Tell me what you wish existed. I build small tools, and requests here often turn into the next one. I will also email you when something new ships.</p>
361
+ <form id="askForm">
362
+ <textarea id="askMsg" placeholder="What would make your workflow easier?" maxlength="2000"></textarea>
363
+ <input type="email" id="askEmail" placeholder="you@email.com" autocomplete="email" required>
364
+ <button type="submit" class="btn-primary" id="askBtn">Send</button>
365
+ </form>
366
+ </div>
367
+
368
+ <button class="help-btn" id="helpBtn" style="display:none;">
369
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
370
+ Request a feature
371
+ </button>
372
+
373
+ <div class="credit">
374
+ <a href="https://www.mahdicreates.com" target="_blank" rel="noopener noreferrer" title="Made by Md Mahdi Hasan - mahdicreates.com">
375
+ <span class="who"><span>Creator</span><b>Md Mahdi Hasan</b></span>
376
+ <svg class="go" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg>
377
+ </a>
378
+ </div>
379
+ </aside>
380
+
381
+ <!-- ---------------- main ---------------- -->
382
+ <main class="main">
150
383
  <header>
151
384
  <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>
385
+ <h2 id="viewTitle" style="font-size:20px;letter-spacing:-0.2px;">All servers</h2>
386
+ <p id="viewSub">One click to run.</p>
154
387
  </div>
155
388
  <div class="head-actions">
156
389
  <button class="btn-ghost" id="checkAll" title="Re-check which servers are online">
@@ -178,7 +411,6 @@
178
411
  </div>
179
412
  </div>
180
413
 
181
- <div class="filters" id="filters"></div>
182
414
  <div class="grid" id="grid"></div>
183
415
 
184
416
  <div class="empty" id="empty" style="display:none;">
@@ -186,6 +418,22 @@
186
418
  <p>Add your first local server so you always know which command runs which project.</p>
187
419
  <button class="btn-primary" onclick="openModal()">+ Add your first server</button>
188
420
  </div>
421
+
422
+ </main>
423
+ </div>
424
+ </div>
425
+
426
+ <div class="overlay" id="dlgOverlay">
427
+ <div class="modal dlg">
428
+ <h2 id="dlgTitle"></h2>
429
+ <div class="sub" id="dlgSub" style="display:none"></div>
430
+ <div class="body" id="dlgBody"></div>
431
+ <input type="text" id="dlgInput" style="display:none">
432
+ <div class="modal-actions">
433
+ <button class="btn-ghost" id="dlgCancel">Cancel</button>
434
+ <button class="btn-primary" id="dlgOk">OK</button>
435
+ </div>
436
+ </div>
189
437
  </div>
190
438
 
191
439
  <div class="overlay" id="overlay">
@@ -203,9 +451,8 @@
203
451
  <input type="text" id="f_project" placeholder="e.g. Client redesign">
204
452
  </div>
205
453
  <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>
454
+ <label>Folder</label>
455
+ <select id="f_folder"></select>
209
456
  </div>
210
457
  </div>
211
458
  <div class="field">
@@ -247,7 +494,8 @@
247
494
 
248
495
  <script>
249
496
  let servers = [];
250
- let activeCat = 'all';
497
+ let folders = []; // [{id, name}]
498
+ let activeFolder = 'all'; // 'all' | 'none' | folder id
251
499
 
252
500
  /* ---------- api ---------- */
253
501
  async function api(path, opts) {
@@ -266,6 +514,37 @@ async function loadPlatform() {
266
514
  }
267
515
  } catch (e) { /* label just stays generic */ }
268
516
  }
517
+ async function loadFolders() {
518
+ try { folders = await api('/api/folders'); } catch (e) { folders = []; }
519
+ if (!Array.isArray(folders)) folders = [];
520
+ }
521
+ let folderTimer = null;
522
+ function persistFolders() {
523
+ clearTimeout(folderTimer);
524
+ folderTimer = setTimeout(function () {
525
+ fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(folders) });
526
+ }, 150);
527
+ }
528
+
529
+ // Folders replaced categories. Anyone upgrading keeps their grouping: each distinct
530
+ // category becomes a folder and its servers move in. Runs once, then never again.
531
+ function migrateCategories() {
532
+ if (folders.length) return false;
533
+ const names = [...new Set(servers.map(s => (s.category || '').trim()).filter(Boolean))];
534
+ if (!names.length) return false;
535
+ names.sort((a, b) => a.localeCompare(b));
536
+ folders = names.map(n => ({ id: 'f_' + Math.random().toString(36).slice(2, 9), name: n }));
537
+ const byName = {};
538
+ folders.forEach(f => { byName[f.name] = f.id; });
539
+ servers.forEach(s => {
540
+ const c = (s.category || '').trim();
541
+ if (c && byName[c] && !s.folder) s.folder = byName[c];
542
+ });
543
+ persistFolders();
544
+ persist();
545
+ return true;
546
+ }
547
+
269
548
  async function loadServers() {
270
549
  try { servers = await api('/api/servers'); }
271
550
  catch (e) { servers = []; }
@@ -273,13 +552,18 @@ async function loadServers() {
273
552
  }
274
553
  let saveTimer = null;
275
554
  let pendingSaves = 0;
555
+ let dirty = false;
276
556
  function persist() {
557
+ // Claim the change immediately. pendingSaves only rises once the debounce fires,
558
+ // so without this flag a sync tick landing inside that 150ms window refetches the
559
+ // old list and quietly undoes what the user just did.
560
+ dirty = true;
277
561
  clearTimeout(saveTimer);
278
562
  saveTimer = setTimeout(() => {
279
563
  const clean = servers.map(({ _status, ...rest }) => rest);
280
564
  pendingSaves++;
281
565
  fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(clean) })
282
- .finally(() => { pendingSaves--; });
566
+ .finally(() => { pendingSaves--; dirty = false; });
283
567
  }, 150);
284
568
  }
285
569
 
@@ -288,7 +572,7 @@ function stripIds(arr) { return JSON.stringify(arr.map(({ _status, ...r }) => r)
288
572
  // Pull the latest list from disk so servers saved elsewhere (e.g. the save-server
289
573
  // skill) show up without a manual reload. Skips while editing or mid-save to avoid clobbering.
290
574
  async function syncList() {
291
- if (pendingSaves > 0) return;
575
+ if (dirty || pendingSaves > 0) return;
292
576
  if (document.getElementById('overlay').classList.contains('open')) return;
293
577
  let remote;
294
578
  try { remote = await api('/api/servers'); } catch (e) { return; }
@@ -313,7 +597,11 @@ function normalizeUrl(raw) {
313
597
  }
314
598
  function portOf(s) {
315
599
  if (s.port) return s.port;
316
- const m = String(s.url || '').match(/:(\d{2,5})\b/);
600
+ const raw = String(s.url || '').trim();
601
+ // The URL field accepts a bare port, so read that too. Without this a card saved as
602
+ // "5173" reported no port at all, which skipped the clash badge and the collision check.
603
+ if (/^\d{2,5}$/.test(raw)) return Number(raw);
604
+ const m = raw.match(/:(\d{2,5})\b/);
317
605
  return m ? Number(m[1]) : null;
318
606
  }
319
607
 
@@ -327,21 +615,271 @@ function toast(msg, isErr) {
327
615
  }
328
616
 
329
617
  /* ---------- categories / filters ---------- */
330
- function categories() {
331
- return [...new Set(servers.map(s => (s.category || '').trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
618
+ function folderName(id) { const f = folders.find(x => x.id === id); return f ? f.name : ''; }
619
+ function countIn(id) {
620
+ if (id === 'all') return servers.length;
621
+ if (id === 'none') return servers.filter(s => !s.folder || !folders.some(f => f.id === s.folder)).length;
622
+ return servers.filter(s => s.folder === id).length;
623
+ }
624
+
625
+ const ICON_ALL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
626
+ const ICON_FOLDER = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
627
+ const ICON_PEN = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>';
628
+
629
+ function renderFolders() {
630
+ const el = document.getElementById('folders');
631
+ if (!el) return;
632
+ const row = (key, icon, label, editable) =>
633
+ '<div class="f-item ' + (activeFolder === key ? 'active' : '') + '" data-folder="' + key + '" ' +
634
+ (key !== 'all' && key !== 'none' ? 'data-drop="1"' : '') + '>' +
635
+ icon +
636
+ '<span class="f-name">' + esc(label) + '</span>' +
637
+ '<span class="n">' + countIn(key) + '</span>' +
638
+ (editable
639
+ ? '<button class="f-edit" data-edit="' + key + '" title="Rename or delete">' + ICON_PEN + '</button>'
640
+ : '<button class="f-edit" disabled tabindex="-1" aria-hidden="true">' + ICON_PEN + '</button>') +
641
+ '</div>';
642
+
643
+ let html = row('all', ICON_ALL, 'All servers', false);
644
+ if (folders.length) html += '<div class="f-sep"></div>';
645
+ folders.forEach(f => { html += row(f.id, ICON_FOLDER, f.name, true); });
646
+ if (countIn('none') && folders.length) html += row('none', ICON_FOLDER, 'Unfiled', false);
647
+ el.innerHTML = html;
648
+
649
+ const title = document.getElementById('viewTitle');
650
+ const sub = document.getElementById('viewSub');
651
+ if (title) title.textContent = activeFolder === 'all' ? 'All servers'
652
+ : activeFolder === 'none' ? 'Unfiled' : (folderName(activeFolder) || 'All servers');
653
+ if (sub) sub.textContent = countIn(activeFolder) + (countIn(activeFolder) === 1 ? ' server' : ' servers');
332
654
  }
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('');
655
+
656
+ function setFolder(k) { activeFolder = k; render(); }
657
+
658
+ async function newFolder() {
659
+ const raw = await ssPrompt('New folder', { placeholder: 'Client work', okText: 'Create' });
660
+ const name = (raw || '').trim();
661
+ if (!name) return;
662
+ if (folders.some(f => f.name.toLowerCase() === name.toLowerCase())) { toast('A folder with that name already exists.', true); return; }
663
+ const f = { id: 'f_' + Math.random().toString(36).slice(2, 9), name: name };
664
+ folders.push(f);
665
+ persistFolders(); activeFolder = f.id; render();
666
+ toast('Folder "' + name + '" created');
341
667
  }
342
- function setCat(c) { activeCat = c; render(); }
343
- function refreshCatList() {
344
- document.getElementById('catList').innerHTML = categories().map(c => `<option value="${esc(c)}">`).join('');
668
+
669
+ async function editFolder(id) {
670
+ const f = folders.find(x => x.id === id);
671
+ if (!f) return;
672
+ const name = await ssPrompt('Rename folder', {
673
+ value: f.name, okText: 'Rename',
674
+ body: 'Clear the box and save to delete this folder instead.',
675
+ });
676
+ if (name === null) return;
677
+ const trimmed = name.trim();
678
+ if (!trimmed) {
679
+ const n = countIn(id);
680
+ const ok = await ssConfirm('Delete "' + f.name + '"?',
681
+ n ? 'Its ' + n + ' server' + (n === 1 ? '' : 's') + ' will move to Unfiled. Nothing is deleted.'
682
+ : 'This folder is empty.',
683
+ { okText: 'Delete folder', danger: true });
684
+ if (!ok) return;
685
+ folders = folders.filter(x => x.id !== id);
686
+ servers.forEach(s => { if (s.folder === id) delete s.folder; });
687
+ if (activeFolder === id) activeFolder = 'all';
688
+ persistFolders(); persist(); render();
689
+ toast('Folder deleted, servers kept');
690
+ return;
691
+ }
692
+ f.name = trimmed;
693
+ persistFolders(); render();
694
+ }
695
+
696
+ function closeMenu() { const m = document.getElementById('moveMenu'); if (m) m.remove(); }
697
+
698
+ function openMoveMenu(e, id) {
699
+ e.stopPropagation();
700
+ closeMenu();
701
+ const s = servers.find(x => x.id === id);
702
+ if (!s) return;
703
+ const m = document.createElement('div');
704
+ m.className = 'menu'; m.id = 'moveMenu';
705
+ const tick = '<svg class="tick" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6"><polyline points="20 6 9 17 4 12"/></svg>';
706
+ const row = (fid, label) =>
707
+ '<button data-move="' + esc(fid) + '" class="' + ((s.folder || '') === fid ? 'on' : '') + '">' +
708
+ ICON_FOLDER + '<span>' + esc(label) + '</span>' + tick + '</button>';
709
+ m.innerHTML = '<div class="menu-label">Move to</div>' +
710
+ row('', 'No folder') +
711
+ folders.map(f => row(f.id, f.name)).join('') +
712
+ (folders.length ? '' : '<div class="menu-label" style="text-transform:none;letter-spacing:0">Create a folder first</div>');
713
+ document.body.appendChild(m);
714
+
715
+ // keep it on screen
716
+ const r = e.currentTarget.getBoundingClientRect();
717
+ const mw = m.offsetWidth, mh = m.offsetHeight;
718
+ m.style.left = Math.max(8, Math.min(r.left, window.innerWidth - mw - 8)) + 'px';
719
+ m.style.top = (r.bottom + mh + 8 > window.innerHeight ? Math.max(8, r.top - mh - 6) : r.bottom + 6) + 'px';
720
+
721
+ m.addEventListener('click', ev => {
722
+ const b = ev.target.closest('[data-move]');
723
+ if (!b) return;
724
+ moveToFolder(id, b.getAttribute('data-move'));
725
+ closeMenu();
726
+ });
727
+ }
728
+ document.addEventListener('click', closeMenu);
729
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
730
+
731
+ // Same rule the skill uses: assigned ports start at 3001 and climb to the first free one.
732
+ function nextFreePort(excludeId) {
733
+ const used = new Set(servers.filter(s => s.id !== excludeId).map(s => portOf(s)).filter(Boolean));
734
+ let p = 3001;
735
+ while (used.has(p)) p++;
736
+ return p;
737
+ }
738
+
739
+ async function fixPortClash(e, id) {
740
+ e.stopPropagation();
741
+ const s = servers.find(x => x.id === id);
742
+ if (!s) return;
743
+ const oldPort = portOf(s);
744
+ if (!oldPort) return;
745
+ const newPort = nextFreePort(id);
746
+ const others = servers.filter(x => x.id !== id && portOf(x) === oldPort).map(x => x.name || 'untitled');
747
+
748
+ // Ask the project what it can actually do before promising anything.
749
+ let plan = { ok: false, reason: 'could not read the project' };
750
+ if (s.cwd) {
751
+ try {
752
+ plan = await api('/api/setport', { method: 'POST', headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({ cwd: s.cwd, port: newPort, apply: false }) });
754
+ } catch (err) {}
755
+ } else plan = { ok: false, reason: 'this server has no project folder set' };
756
+
757
+ const msg =
758
+ 'Move "' + (s.name || 'this server') + '" from port ' + oldPort + ' to ' + newPort + '?\n\n' +
759
+ 'Port ' + oldPort + ' is also used by: ' + others.join(', ') + '\n\n' +
760
+ (plan.ok
761
+ ? 'The project will be updated too:\n "' + plan.key + '": ' + JSON.stringify(plan.before) +
762
+ '\n becomes ' + JSON.stringify(plan.after) + '\n\nA backup of package.json is kept.'
763
+ : 'The project config cannot be updated automatically (' + plan.reason + ').\n' +
764
+ 'Only the card changes, so set port ' + newPort + ' in the project yourself.');
765
+ if (!(await ssConfirm('Move to port ' + newPort + '?', msg, { okText: 'Move' }))) return;
766
+
767
+ if (plan.ok) {
768
+ let applied = { ok: false };
769
+ try {
770
+ applied = await api('/api/setport', { method: 'POST', headers: { 'Content-Type': 'application/json' },
771
+ body: JSON.stringify({ cwd: s.cwd, port: newPort, apply: true }) });
772
+ } catch (err) {}
773
+ if (!applied.ok) { toast('Could not update the project: ' + (applied.reason || 'error'), true); return; }
774
+ }
775
+
776
+ const rx = new RegExp('\\b' + oldPort + '\\b', 'g');
777
+ s.url = String(s.url || '').replace(rx, String(newPort));
778
+ if (s.command) s.command = s.command.replace(rx, String(newPort));
779
+ s._status = 'unknown';
780
+ persist(); render(); refreshStatus();
781
+ toast(plan.ok ? 'Moved to port ' + newPort + ', project updated.'
782
+ : 'Card moved to ' + newPort + '. Set it in the project too.');
783
+ }
784
+
785
+ /* ---------- in-app dialogs, so no "localhost says" boxes ---------- */
786
+ let dlgResolve = null;
787
+
788
+ function closeDlg(value) {
789
+ const o = document.getElementById('dlgOverlay');
790
+ o.classList.remove('open');
791
+ const r = dlgResolve; dlgResolve = null;
792
+ if (r) r(value);
793
+ }
794
+
795
+ function openDlg(opts) {
796
+ return new Promise(resolve => {
797
+ if (dlgResolve) closeDlg(null);
798
+ dlgResolve = resolve;
799
+ const o = document.getElementById('dlgOverlay');
800
+ document.getElementById('dlgTitle').textContent = opts.title || '';
801
+ const body = document.getElementById('dlgBody');
802
+ body.textContent = opts.body || '';
803
+ body.style.display = opts.body ? '' : 'none';
804
+ const input = document.getElementById('dlgInput');
805
+ if (opts.input) {
806
+ input.style.display = '';
807
+ input.value = opts.value || '';
808
+ input.placeholder = opts.placeholder || '';
809
+ } else input.style.display = 'none';
810
+ const ok = document.getElementById('dlgOk');
811
+ ok.textContent = opts.okText || 'OK';
812
+ ok.className = opts.danger ? 'btn-danger' : 'btn-primary';
813
+ document.getElementById('dlgCancel').textContent = opts.cancelText || 'Cancel';
814
+ o.classList.add('open');
815
+ setTimeout(() => (opts.input ? input : ok).focus(), 60);
816
+ });
817
+ }
818
+
819
+ // Drop-in replacements for the browser's own dialogs.
820
+ function ssConfirm(title, body, opts) {
821
+ opts = opts || {};
822
+ return openDlg({ title, body, okText: opts.okText || 'Confirm', danger: opts.danger })
823
+ .then(v => v !== null);
824
+ }
825
+ function ssPrompt(title, opts) {
826
+ opts = opts || {};
827
+ return openDlg({ title, body: opts.body, input: true, value: opts.value,
828
+ placeholder: opts.placeholder, okText: opts.okText || 'Save' });
829
+ }
830
+
831
+ /* ---------- version + update ---------- */
832
+ let updateInfo = null;
833
+
834
+ async function checkUpdate() {
835
+ const pill = document.getElementById('verPill');
836
+ if (!pill) return;
837
+ let r = null;
838
+ try { r = await api('/api/update'); } catch (e) {}
839
+ if (!r || !r.current) { pill.style.display = 'none'; return; }
840
+ updateInfo = r;
841
+ pill.classList.remove('checking');
842
+
843
+ if (r.newer) {
844
+ pill.className = 'ver has-update';
845
+ pill.innerHTML = '<span class="dot-new"></span>v' + esc(r.current) + ' &rarr; ' + esc(r.latest);
846
+ pill.title = 'Version ' + r.latest + ' is available. Click to update.';
847
+ pill.onclick = runUpdate;
848
+ } else {
849
+ pill.className = 'ver';
850
+ pill.textContent = 'v' + r.current;
851
+ pill.title = r.latest ? 'Up to date' : 'Version ' + r.current + ' (could not reach npm)';
852
+ pill.onclick = null;
853
+ }
854
+ }
855
+
856
+ async function runUpdate() {
857
+ if (!updateInfo || !updateInfo.newer) return;
858
+ const ok = await ssConfirm('Update to ' + updateInfo.latest + '?',
859
+ 'You are on ' + updateInfo.current + '. This runs npm install -g server-studio in a terminal window.\n\nYour saved servers and folders are not touched. Restart Server Studio when it finishes.',
860
+ { okText: 'Update' });
861
+ if (!ok) return;
862
+ const r = await api('/api/run', { method: 'POST', headers: { 'Content-Type': 'application/json' },
863
+ body: JSON.stringify({ cwd: '', command: 'npm install -g server-studio' }) });
864
+ if (r && r.ok) toast('Updating in ' + TERM_NAME + '. Restart Server Studio when it finishes.');
865
+ else toast('Could not start the update. Run: npm install -g server-studio', true);
866
+ }
867
+
868
+ function moveToFolder(serverId, folderId) {
869
+ const s = servers.find(x => x.id === serverId);
870
+ if (!s) return;
871
+ if (folderId) s.folder = folderId; else delete s.folder;
872
+ persist(); render();
873
+ toast(folderId ? 'Moved to ' + folderName(folderId) : 'Moved out of the folder');
874
+ }
875
+
876
+ function refreshFolderList() {
877
+ const sel = document.getElementById('f_folder');
878
+ if (!sel) return;
879
+ const keep = sel.value;
880
+ sel.innerHTML = '<option value="">No folder</option>' +
881
+ folders.map(f => '<option value="' + esc(f.id) + '">' + esc(f.name) + '</option>').join('');
882
+ sel.value = keep;
345
883
  }
346
884
 
347
885
  /* ---------- render ---------- */
@@ -350,16 +888,27 @@ function render() {
350
888
  const grid = document.getElementById('grid');
351
889
  const empty = document.getElementById('empty');
352
890
 
353
- if (activeCat !== 'all' && !categories().includes(activeCat)) activeCat = 'all';
354
- renderFilters();
355
- refreshCatList();
891
+ if (activeFolder !== 'all' && activeFolder !== 'none' && !folders.some(f => f.id === activeFolder)) activeFolder = 'all';
892
+ renderFolders();
893
+ refreshFolderList();
356
894
 
895
+ const inFolder = s => {
896
+ if (activeFolder === 'all') return true;
897
+ if (activeFolder === 'none') return !s.folder || !folders.some(f => f.id === s.folder);
898
+ return s.folder === activeFolder;
899
+ };
357
900
  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))
901
+ inFolder(s) &&
902
+ (!q || [s.name, s.project, s.url, s.tag, folderName(s.folder), s.command, s.note].join(' ').toLowerCase().includes(q))
360
903
  );
361
904
  list.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
362
905
 
906
+ // A port used by more than one project breaks the promise that a saved card keeps
907
+ // working, so say so on both cards rather than letting it fail silently later.
908
+ const portUse = {};
909
+ servers.forEach(s => { const p = portOf(s); if (p) (portUse[p] = portUse[p] || []).push(s.id); });
910
+ const clash = id => { const p = portOf(servers.find(x => x.id === id) || {}); return p && portUse[p] && portUse[p].length > 1; };
911
+
363
912
  document.getElementById('countTotal').textContent = servers.length;
364
913
  document.getElementById('countUp').textContent = servers.filter(s => s._status === 'up').length;
365
914
 
@@ -372,14 +921,14 @@ function render() {
372
921
  const st = s._status || 'unknown';
373
922
  const port = portOf(s);
374
923
  return `
375
- <div class="card ${s.pinned ? 'pinned' : ''}">
924
+ <div class="card ${s.pinned ? 'pinned' : ''}" draggable="true" data-id="${s.id}">
376
925
  <div class="card-top">
377
926
  <div class="card-name">
378
927
  <button class="pin-star ${s.pinned ? 'on' : ''}" onclick="togglePin('${s.id}')" title="Pin to top">${s.pinned ? '★' : '☆'}</button>
379
928
  ${esc(s.name) || 'Untitled'}
380
929
  </div>
381
930
  <div class="badges">
382
- ${s.category ? `<span class="cat">${esc(s.category)}</span>` : ''}
931
+ ${s.folder && folderName(s.folder) ? `<span class="cat">${esc(folderName(s.folder))}</span>` : ''}
383
932
  ${s.tag ? `<span class="tag">${esc(s.tag)}</span>` : ''}
384
933
  </div>
385
934
  </div>
@@ -388,7 +937,7 @@ function render() {
388
937
  ${esc(s.project)}</div>` : ''}
389
938
  ${url ? `<div class="url-row">
390
939
  <span class="status ${st === 'unknown' ? '' : st}" title="${st}"></span>
391
- <span class="url-text">${esc(url.replace(/^https?:\/\//, ''))}</span>
940
+ <span class="url-text">${esc(url.replace(/^https?:\/\//, ''))}</span>${clash(s.id) ? `<button class="conflict" onclick="fixPortClash(event, '${s.id}')" title="Another project already uses this port. Click to move this one to a free port.">port clash &middot; fix</button>` : ''}
392
941
  <button style="padding:2px 4px;border:none;background:none;color:var(--text-faint)" onclick="copyUrl('${url}', this)" title="Copy URL">
393
942
  <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
943
  </button>
@@ -396,18 +945,28 @@ function render() {
396
945
  ${s.command ? `<div class="cmd-line">
397
946
  <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
947
  <span>${esc(s.command)}</span></div>` : ''}
948
+ ${s.command && !s.cwd ? `<div class="note" style="color:var(--amber)">No project folder set, so Run has nowhere to go. Add one in Edit.</div>` : ''}
399
949
  ${s.note ? `<div class="note">${esc(s.note)}</div>` : ''}
400
950
  <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>`
951
+ ${s.command || port
952
+ ? (st === 'checking'
953
+ ? `<button class="btn btn-busy main" disabled>
954
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.2-8.5"/></svg> Working</button>`
955
+ : st === 'up' && port
956
+ ? `<button class="btn btn-stop main" onclick="stopServer('${s.id}')" title="Stop whatever is on port ${port}">
957
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Stop</button>`
958
+ : s.command
959
+ ? `<button class="btn btn-run main" onclick="runServer('${s.id}')" title="Run">
960
+ <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>`
961
+ : '')
404
962
  : ''}
405
963
  ${url
406
964
  ? `<a class="btn ${s.command ? '' : 'btn-primary main'}" href="${url}" target="_blank" rel="noopener" title="Open in browser">
407
965
  <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
966
  : ''}
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>` : ''}
967
+ <button class="icon-btn" onclick="openMoveMenu(event, '${s.id}')" title="Move to folder">
968
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
969
+ </button>
411
970
  <button class="icon-btn" onclick="editServer('${s.id}')" title="Edit">
412
971
  <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
972
  <button class="icon-btn" onclick="delServer('${s.id}')" title="Delete">
@@ -421,14 +980,26 @@ function render() {
421
980
  async function runServer(id) {
422
981
  const s = servers.find(x => x.id === id);
423
982
  if (!s || !s.command) return;
983
+ // With no folder the command runs in the home directory, where it either fails with
984
+ // something confusing like "Missing script: dev" or, worse, does something unintended.
985
+ if (!s.cwd) {
986
+ toast('No project folder set for "' + (s.name || 'this server') + '". Set it first.', true);
987
+ openModal(id);
988
+ setTimeout(() => { const el = document.getElementById('f_cwd'); if (el) el.focus(); }, 120);
989
+ return;
990
+ }
424
991
  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);
992
+ if (r.ok) {
993
+ toast('Running "' + (s.name || 'server') + '" in ' + TERM_NAME);
994
+ if (portOf(s)) { s._status = 'checking'; render(); }
995
+ setTimeout(() => refreshStatus(), 3500);
996
+ } else toast('Could not run: ' + (r.error || 'error'), true);
427
997
  }
428
998
  async function stopServer(id) {
429
999
  const s = servers.find(x => x.id === id);
430
1000
  const port = portOf(s);
431
1001
  if (!port) return;
1002
+ s._status = 'checking'; render();
432
1003
  await api('/api/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port }) });
433
1004
  toast('Stopped whatever was on port ' + port);
434
1005
  setTimeout(() => refreshStatus(), 700);
@@ -447,28 +1018,51 @@ function openModal(id) {
447
1018
  document.getElementById('modalTitle').textContent = id ? 'Edit server' : 'Add server';
448
1019
  document.getElementById('editId').value = id || '';
449
1020
  const s = servers.find(x => x.id === id) || {};
450
- f_name.value = s.name || ''; f_project.value = s.project || ''; f_category.value = s.category || '';
1021
+ f_name.value = s.name || ''; f_project.value = s.project || ''; f_folder.value = s.folder || '';
451
1022
  f_cwd.value = s.cwd || ''; f_command.value = s.command || ''; f_url.value = s.url || '';
452
1023
  f_tag.value = s.tag || ''; f_note.value = s.note || '';
453
1024
  setTimeout(() => f_name.focus(), 50);
454
1025
  }
455
1026
  function closeModal() { document.getElementById('overlay').classList.remove('open'); }
456
- function saveServer() {
1027
+ async function saveServer() {
457
1028
  const id = document.getElementById('editId').value;
458
1029
  const data = {
459
- name: f_name.value.trim(), project: f_project.value.trim(), category: f_category.value.trim(),
1030
+ name: f_name.value.trim(), project: f_project.value.trim(), folder: f_folder.value || '',
460
1031
  cwd: f_cwd.value.trim(), command: f_command.value.trim(), url: f_url.value.trim(),
461
1032
  tag: f_tag.value.trim(), note: f_note.value.trim(),
462
1033
  };
463
1034
  if (!data.name && !data.url && !data.command) { toast('Add at least a name, URL or command.', true); return; }
1035
+
1036
+ // The whole point of this app is one permanent port per project, so catch a collision
1037
+ // here rather than letting two cards fight over a port and fail at run time.
1038
+ const wanted = portOf(data);
1039
+ if (wanted) {
1040
+ const taken = servers.find(x => x.id !== id && portOf(x) === wanted);
1041
+ if (taken) {
1042
+ const free = nextFreePort(id);
1043
+ const useFree = await ssConfirm('Port ' + wanted + ' is already taken',
1044
+ '"' + (taken.name || 'another server') + '" already uses it, and two projects on one port means whichever starts second fails.\n\nUse port ' + free + ' instead?',
1045
+ { okText: 'Use ' + free });
1046
+ if (useFree) {
1047
+ const rx = new RegExp('\\b' + wanted + '\\b', 'g');
1048
+ data.url = String(data.url || '').replace(rx, String(free));
1049
+ if (data.command) data.command = data.command.replace(rx, String(free));
1050
+ toast('Using port ' + free + '. Set it in the project config too.');
1051
+ }
1052
+ }
1053
+ }
1054
+
464
1055
  if (id) { Object.assign(servers.find(x => x.id === id), data); }
465
1056
  else { servers.push({ id: uid(), pinned: false, ...data }); }
466
1057
  persist(); closeModal(); render(); refreshStatus();
467
1058
  }
468
1059
  function editServer(id) { openModal(id); }
469
- function delServer(id) {
1060
+ async function delServer(id) {
470
1061
  const s = servers.find(x => x.id === id);
471
- if (confirm(`Delete "${s.name || 'this server'}"? This only removes it from the list.`)) {
1062
+ const ok = await ssConfirm('Delete "' + (s.name || 'this server') + '"?',
1063
+ 'This only removes the card. Your project and its files are untouched.',
1064
+ { okText: 'Delete', danger: true });
1065
+ if (ok) {
472
1066
  servers = servers.filter(x => x.id !== id); persist(); render();
473
1067
  }
474
1068
  }
@@ -520,7 +1114,75 @@ async function migrateFromLocalStorage() {
520
1114
  } catch (e) {}
521
1115
  }
522
1116
 
1117
+ /* ---------- email signup ---------- */
1118
+ /* ---------- ask box: requests + email, docked bottom-left ---------- */
1119
+ function showAsk(open) {
1120
+ const box = document.getElementById('ask');
1121
+ const btn = document.getElementById('helpBtn');
1122
+ if (!box || !btn) return;
1123
+ box.style.display = open ? '' : 'none';
1124
+ btn.style.display = open ? 'none' : '';
1125
+ }
1126
+
1127
+ async function initAsk() {
1128
+ try {
1129
+ const r = await api('/api/subscribe'); // GET -> { enabled }
1130
+ if (!r || !r.enabled) { showAsk(false); document.getElementById('helpBtn').style.display = 'none'; return; }
1131
+ const state = localStorage.getItem('ss_ask');
1132
+ showAsk(state !== 'hide');
1133
+ } catch (e) {
1134
+ showAsk(false);
1135
+ const b = document.getElementById('helpBtn'); if (b) b.style.display = 'none';
1136
+ }
1137
+ }
1138
+
1139
+ function dismissAsk() {
1140
+ localStorage.setItem('ss_ask', 'hide');
1141
+ showAsk(false);
1142
+ // Tell them where it went, once, so it does not feel like it vanished.
1143
+ toast('Moved to "Request a feature" in the sidebar. Open it any time.');
1144
+ }
1145
+
1146
+ async function sendAsk(e) {
1147
+ e.preventDefault();
1148
+ const email = document.getElementById('askEmail').value.trim();
1149
+ const message = document.getElementById('askMsg').value.trim();
1150
+ const btn = document.getElementById('askBtn');
1151
+ if (!email) return;
1152
+ btn.disabled = true; btn.textContent = 'Sending...';
1153
+ let ok = false;
1154
+ try {
1155
+ const r = await api('/api/subscribe', {
1156
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1157
+ body: JSON.stringify({ email: email, message: message }),
1158
+ });
1159
+ ok = !!(r && r.ok);
1160
+ } catch (err) { ok = false; }
1161
+ btn.disabled = false; btn.textContent = 'Send';
1162
+ if (ok) {
1163
+ document.getElementById('ask').innerHTML =
1164
+ '<div class="ask-ok">Thanks. ' + (message ? 'I read every one of these.' : 'You are on the list.') + '</div>';
1165
+ localStorage.setItem('ss_ask', 'done');
1166
+ setTimeout(function () { showAsk(false); }, 2600);
1167
+ } else {
1168
+ toast('Could not send that. Try again in a moment.', true);
1169
+ }
1170
+ }
1171
+
523
1172
  /* ---------- wire up ---------- */
1173
+ document.getElementById('dlgOk').onclick = () => {
1174
+ const input = document.getElementById('dlgInput');
1175
+ closeDlg(input.style.display === 'none' ? true : input.value);
1176
+ };
1177
+ document.getElementById('dlgCancel').onclick = () => closeDlg(null);
1178
+ document.getElementById('dlgOverlay').onclick = e => { if (e.target.id === 'dlgOverlay') closeDlg(null); };
1179
+ document.getElementById('dlgInput').onkeydown = e => {
1180
+ if (e.key === 'Enter') { e.preventDefault(); closeDlg(e.target.value); }
1181
+ };
1182
+ document.addEventListener('keydown', e => {
1183
+ if (e.key === 'Escape' && document.getElementById('dlgOverlay').classList.contains('open')) closeDlg(null);
1184
+ });
1185
+
524
1186
  document.getElementById('addBtn').onclick = () => openModal();
525
1187
  document.getElementById('saveBtn').onclick = saveServer;
526
1188
  document.getElementById('browseBtn').onclick = browseFolder;
@@ -538,9 +1200,53 @@ document.addEventListener('keydown', e => {
538
1200
  (async function init() {
539
1201
  await loadPlatform();
540
1202
  await loadServers();
1203
+ await loadFolders();
541
1204
  await migrateFromLocalStorage();
1205
+ migrateCategories();
542
1206
  render();
543
1207
  refreshStatus();
1208
+ initAsk();
1209
+ checkUpdate();
1210
+
1211
+ document.getElementById('folders').addEventListener('click', function (e) {
1212
+ const edit = e.target.closest('[data-edit]');
1213
+ if (edit) { e.stopPropagation(); editFolder(edit.getAttribute('data-edit')); return; }
1214
+ const item = e.target.closest('[data-folder]');
1215
+ if (item) setFolder(item.getAttribute('data-folder'));
1216
+ });
1217
+ document.getElementById('newFolder').onclick = newFolder;
1218
+
1219
+ // Drag a card onto a folder in the sidebar. Touch users get the move menu instead.
1220
+ let dragId = null;
1221
+ document.getElementById('grid').addEventListener('dragstart', e => {
1222
+ const card = e.target.closest('.card[data-id]');
1223
+ if (!card) return;
1224
+ dragId = card.getAttribute('data-id');
1225
+ card.classList.add('dragging');
1226
+ e.dataTransfer.effectAllowed = 'move';
1227
+ try { e.dataTransfer.setData('text/plain', dragId); } catch (err) {}
1228
+ });
1229
+ document.getElementById('grid').addEventListener('dragend', e => {
1230
+ const card = e.target.closest('.card'); if (card) card.classList.remove('dragging');
1231
+ dragId = null;
1232
+ });
1233
+ const nav = document.getElementById('folders');
1234
+ nav.addEventListener('dragover', e => {
1235
+ const t = e.target.closest('[data-drop]'); if (!t || !dragId) return;
1236
+ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; t.classList.add('drop');
1237
+ });
1238
+ nav.addEventListener('dragleave', e => {
1239
+ const t = e.target.closest('[data-drop]'); if (t) t.classList.remove('drop');
1240
+ });
1241
+ nav.addEventListener('drop', e => {
1242
+ const t = e.target.closest('[data-drop]'); if (!t || !dragId) return;
1243
+ e.preventDefault(); t.classList.remove('drop');
1244
+ moveToFolder(dragId, t.getAttribute('data-folder'));
1245
+ dragId = null;
1246
+ });
1247
+ document.getElementById('askDismiss').onclick = dismissAsk;
1248
+ document.getElementById('helpBtn').onclick = function () { localStorage.removeItem('ss_ask'); showAsk(true); };
1249
+ document.getElementById('askForm').onsubmit = sendAsk;
544
1250
  setInterval(refreshStatus, 12000);
545
1251
  setInterval(syncList, 4000);
546
1252
  })();