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/CHANGELOG.md +85 -0
- package/README.md +95 -10
- package/bin/cli.js +3 -0
- package/dist/server-studio.plugin +0 -0
- package/package.json +4 -2
- package/skill/SKILL.md +3 -2
- package/skill/register-server.js +21 -2
- package/src/index.html +751 -45
- package/src/server.js +99 -0
- package/src/setport.js +82 -0
- package/src/telemetry.js +174 -0
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
|
-
|
|
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">×</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
|
-
<
|
|
153
|
-
<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>
|
|
207
|
-
<
|
|
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
|
|
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
|
|
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
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
function
|
|
344
|
-
|
|
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) + ' → ' + 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 (
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
(
|
|
359
|
-
(!q || [s.name, s.project, s.url, s.tag, s.
|
|
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.
|
|
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 · 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
|
-
?
|
|
403
|
-
|
|
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
|
-
|
|
410
|
-
<svg width="
|
|
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) {
|
|
426
|
-
|
|
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 || '';
|
|
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(),
|
|
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
|
-
|
|
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
|
})();
|