@vnphu/nestjs-api-explorer 0.1.0 → 0.2.1

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.
@@ -106,9 +106,53 @@ function getExplorerHtml(options) {
106
106
  width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
107
107
  border-radius: var(--radius); border: 1.5px solid var(--border);
108
108
  background: var(--bg-white); color: var(--text-muted);
109
- cursor: pointer; transition: all .15s; flex-shrink: 0;
109
+ cursor: pointer; transition: all .15s; flex-shrink: 0; position: relative;
110
110
  }
111
111
  .icon-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
112
+ .icon-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
113
+ .icon-btn .dot {
114
+ position: absolute; top: -3px; right: -3px;
115
+ width: 8px; height: 8px; border-radius: 50%;
116
+ background: var(--get-fg); border: 2px solid var(--bg-white);
117
+ }
118
+
119
+ /* ── Global Auth Dropdown ── */
120
+ #global-auth-dropdown {
121
+ position: absolute; top: 56px; right: 16px; z-index: 100;
122
+ width: 340px; background: var(--bg-white);
123
+ border: 1.5px solid var(--border); border-radius: var(--radius);
124
+ box-shadow: var(--shadow); padding: 16px;
125
+ }
126
+ #global-auth-dropdown.hidden { display: none; }
127
+ .gauth-title {
128
+ font-size: 12px; font-weight: 700; color: var(--text);
129
+ margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
130
+ }
131
+ .gauth-title svg { color: var(--accent); }
132
+ .gauth-active-badge {
133
+ font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 10px;
134
+ background: var(--get-bg); border: 1px solid var(--get-bdr); color: var(--get-fg);
135
+ }
136
+ .gauth-type-row { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 14px; }
137
+ .gauth-type-btn {
138
+ padding: 4px 11px; border-radius: 20px; border: 1.5px solid var(--border);
139
+ background: var(--bg-input); color: var(--text-muted);
140
+ font-size: 11px; font-weight: 500; cursor: pointer; transition: all .12s;
141
+ }
142
+ .gauth-type-btn:hover { border-color: var(--accent); color: var(--accent); }
143
+ .gauth-type-btn.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); font-weight: 600; }
144
+ .gauth-footer { margin-top: 14px; display: flex; gap: 8px; }
145
+ .gauth-save-btn {
146
+ flex: 1; height: 32px; background: var(--accent); color: #fff; border: none;
147
+ border-radius: var(--radius-sm); font-size: 12px; font-weight: 600; cursor: pointer; transition: background .15s;
148
+ }
149
+ .gauth-save-btn:hover { background: #2563eb; }
150
+ .gauth-clear-btn {
151
+ height: 32px; padding: 0 12px; background: var(--bg-input); color: var(--text-muted);
152
+ border: 1.5px solid var(--border); border-radius: var(--radius-sm);
153
+ font-size: 12px; cursor: pointer; transition: all .15s;
154
+ }
155
+ .gauth-clear-btn:hover { border-color: var(--del-fg); color: var(--del-fg); }
112
156
 
113
157
  /* ── Main ── */
114
158
  #main { display: flex; flex: 1; overflow: hidden; }
@@ -137,9 +181,33 @@ function getExplorerHtml(options) {
137
181
  #search::placeholder { color: var(--text-dim); }
138
182
  .search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-dim); pointer-events: none; }
139
183
 
140
- #route-list { flex: 1; overflow-y: auto; padding: 6px 8px 16px; min-height: 0; }
184
+ #route-list { flex: 1; overflow-y: auto; padding: 4px 8px 16px; min-height: 0; }
141
185
  .route-count { font-size: 10px; color: var(--text-dim); padding: 8px 6px 4px; font-weight: 500; }
142
186
 
187
+ .route-group { margin-bottom: 4px; }
188
+ .route-group-header {
189
+ display: flex; align-items: center; gap: 6px;
190
+ padding: 6px 8px 4px; cursor: pointer; user-select: none;
191
+ border-radius: var(--radius-sm);
192
+ transition: background .12s;
193
+ }
194
+ .route-group-header:hover { background: var(--bg-hover); }
195
+ .route-group-name {
196
+ font-size: 11px; font-weight: 700; color: var(--text);
197
+ text-transform: uppercase; letter-spacing: 0.5px; flex: 1;
198
+ }
199
+ .route-group-count {
200
+ font-size: 10px; font-weight: 600; color: var(--text-dim);
201
+ background: var(--bg-hover); border: 1px solid var(--border);
202
+ padding: 1px 6px; border-radius: 10px;
203
+ }
204
+ .route-group-chevron {
205
+ color: var(--text-dim); transition: transform .2s; flex-shrink: 0;
206
+ }
207
+ .route-group-chevron.collapsed { transform: rotate(-90deg); }
208
+ .route-group-body { padding-left: 4px; }
209
+ .route-group-body.collapsed { display: none; }
210
+
143
211
  .route-item {
144
212
  display: flex; align-items: center; gap: 8px;
145
213
  padding: 7px 9px; border-radius: var(--radius-sm);
@@ -166,6 +234,10 @@ function getExplorerHtml(options) {
166
234
  .method-HEAD { background: var(--head-bg); border-color: var(--head-bdr); color: var(--head-fg); }
167
235
  .method-OPTIONS{ background: var(--opt-bg); border-color: var(--opt-bdr); color: var(--opt-fg); }
168
236
 
237
+ .route-desc {
238
+ display: block; font-size: 10px; color: var(--text-dim); margin-top: 1px;
239
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 100%;
240
+ }
169
241
  .route-path {
170
242
  font-family: var(--font-mono); font-size: 11.5px; color: var(--text-muted);
171
243
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
@@ -177,7 +249,81 @@ function getExplorerHtml(options) {
177
249
  #sidebar-resize:hover, #sidebar-resize.dragging { background: var(--accent); opacity: .4; }
178
250
 
179
251
  /* ── Content ── */
180
- #content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
252
+ #content { flex: 1; display: flex; flex-direction: row; overflow: hidden; min-width: 0; }
253
+ #content-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
254
+
255
+ /* ── Summary Panel ── */
256
+ #summary-panel {
257
+ width: 400px; flex-shrink: 0; border-left: 1px solid var(--border);
258
+ background: var(--bg-white); display: flex; flex-direction: column; overflow: hidden;
259
+ }
260
+ #summary-panel.hidden { display: none; }
261
+ .summary-header {
262
+ padding: 10px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0;
263
+ display: flex; align-items: center; justify-content: space-between;
264
+ }
265
+ .summary-header-title { font-size: 11px; font-weight: 700; color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; }
266
+ .summary-copy-btn {
267
+ display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500;
268
+ color: var(--text-muted); background: none; border: 1px solid var(--border);
269
+ border-radius: var(--radius-sm); padding: 3px 8px; cursor: pointer; transition: all 0.15s;
270
+ }
271
+ .summary-copy-btn:hover { background: var(--bg-input); color: var(--accent); border-color: var(--accent); }
272
+ .summary-copy-btn.copied { color: #16a34a; border-color: #86efac; background: #f0fdf4; }
273
+ #summary-body { flex: 1; overflow-y: auto; padding: 10px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
274
+ .summary-section { display: flex; flex-direction: column; gap: 6px; }
275
+ .summary-section-label {
276
+ font-size: 10px; font-weight: 700; color: var(--text-dim);
277
+ text-transform: uppercase; letter-spacing: 0.7px;
278
+ }
279
+ .summary-url {
280
+ font-family: var(--font-mono); font-size: 11px; color: var(--text);
281
+ word-break: break-all; line-height: 1.6; background: var(--bg-input);
282
+ border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px;
283
+ }
284
+ .summary-url .url-path-param { color: var(--put-fg); font-weight: 600; }
285
+ .summary-row {
286
+ display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
287
+ font-size: 11px; padding: 3px 0; border-bottom: 1px dashed var(--border);
288
+ }
289
+ .summary-row:last-child { border-bottom: none; }
290
+ .summary-key { font-family: var(--font-mono); color: var(--accent); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
291
+ .summary-val { font-family: var(--font-mono); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
292
+ .summary-none { font-size: 11px; color: var(--text-dim); font-style: italic; }
293
+ .summary-method-pill {
294
+ display: inline-block; font-family: var(--font-mono); font-size: 10px; font-weight: 700;
295
+ padding: 2px 7px; border-radius: 4px; margin-right: 6px; vertical-align: middle;
296
+ border-width: 1px; border-style: solid;
297
+ }
298
+ .summary-auth-row {
299
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
300
+ padding: 4px 8px; background: var(--bg-input); border-radius: var(--radius-sm);
301
+ border: 1px solid var(--border);
302
+ }
303
+ .summary-auth-icon { color: var(--accent); flex-shrink: 0; }
304
+ .summary-auth-label { color: var(--text-muted); }
305
+ .summary-auth-val { font-family: var(--font-mono); color: var(--text); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
306
+ .summary-body-preview {
307
+ font-family: var(--font-mono); font-size: 11px; color: var(--text-muted);
308
+ background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm);
309
+ padding: 6px 8px; max-height: 100px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5;
310
+ }
311
+ .summary-empty {
312
+ flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
313
+ gap: 8px; padding: 20px; color: var(--text-dim); text-align: center;
314
+ }
315
+ .summary-empty p { font-size: 11px; line-height: 1.6; }
316
+ /* ── DocField rows (body/query/headers/response from docs file) ── */
317
+ .df-row { padding: 5px 0; border-bottom: 1px dashed var(--border); }
318
+ .df-row:last-child { border-bottom: none; }
319
+ .df-top { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
320
+ .df-bottom { margin-top: 2px; }
321
+ .df-name { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--accent); }
322
+ .df-type { font-size: 9px; font-weight: 600; padding: 1px 5px; border-radius: 3px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); }
323
+ .df-req { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; background: #fef2f2; border: 1px solid #fecaca; color: var(--del-fg); }
324
+ .df-opt { font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-dim); }
325
+ .df-rules { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
326
+ .df-desc { font-size: 10px; color: var(--text-muted); font-style: italic; }
181
327
 
182
328
  /* ── URL bar ── */
183
329
  #url-bar {
@@ -462,7 +608,13 @@ function getExplorerHtml(options) {
462
608
  <span class="base-url-label">Base URL</span>
463
609
  <input id="base-url" type="url" spellcheck="false" />
464
610
  </div>
465
- <button class="icon-btn" id="reload-btn" title="Reload routes (r)">
611
+ <button class="icon-btn" id="global-auth-btn" title="Global Authentication">
612
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
613
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
614
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
615
+ </svg>
616
+ </button>
617
+ <button class="icon-btn" id="reload-btn" title="Reload routes">
466
618
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
467
619
  <polyline points="23 4 23 10 17 10"/>
468
620
  <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
@@ -470,6 +622,48 @@ function getExplorerHtml(options) {
470
622
  </button>
471
623
  </header>
472
624
 
625
+ <!-- Global Auth Dropdown -->
626
+ <div id="global-auth-dropdown" class="hidden">
627
+ <div class="gauth-title">
628
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
629
+ Global Authentication
630
+ <span class="gauth-active-badge hidden" id="gauth-active-badge">Active</span>
631
+ </div>
632
+ <div class="gauth-type-row">
633
+ <button class="gauth-type-btn active" data-gauth="none">None</button>
634
+ <button class="gauth-type-btn" data-gauth="bearer">Bearer Token</button>
635
+ <button class="gauth-type-btn" data-gauth="apikey">API Key</button>
636
+ </div>
637
+ <div id="gauth-none-panel" class="text-muted" style="font-size:12px">No global auth. Per-request auth (Auth tab) still applies.</div>
638
+ <div id="gauth-bearer-panel" class="hidden flex-col gap-6">
639
+ <div class="field-group">
640
+ <label class="field-label">Bearer Token</label>
641
+ <input class="kv-input" id="gauth-bearer-token" type="text" style="width:100%" placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…" spellcheck="false" autocomplete="off" />
642
+ </div>
643
+ </div>
644
+ <div id="gauth-apikey-panel" class="hidden flex-col gap-6">
645
+ <div class="field-group">
646
+ <label class="field-label">Key Name</label>
647
+ <input class="kv-input" id="gauth-apikey-name" type="text" style="width:100%" placeholder="X-API-Key" value="X-API-Key" spellcheck="false" />
648
+ </div>
649
+ <div class="field-group">
650
+ <label class="field-label">Key Value</label>
651
+ <input class="kv-input" id="gauth-apikey-value" type="text" style="width:100%" placeholder="your-secret-key" autocomplete="off" spellcheck="false" />
652
+ </div>
653
+ <div class="field-group">
654
+ <label class="field-label">Add To</label>
655
+ <select class="styled-select" id="gauth-apikey-in" style="width:160px">
656
+ <option value="header">Header</option>
657
+ <option value="query">Query String</option>
658
+ </select>
659
+ </div>
660
+ </div>
661
+ <div class="gauth-footer">
662
+ <button class="gauth-save-btn" id="gauth-save-btn">Apply Globally</button>
663
+ <button class="gauth-clear-btn" id="gauth-clear-btn">Clear</button>
664
+ </div>
665
+ </div>
666
+
473
667
  <!-- Main -->
474
668
  <div id="main">
475
669
 
@@ -491,6 +685,7 @@ function getExplorerHtml(options) {
491
685
 
492
686
  <!-- Content -->
493
687
  <div id="content">
688
+ <div id="content-main">
494
689
 
495
690
  <!-- URL bar -->
496
691
  <div id="url-bar">
@@ -635,9 +830,29 @@ function getExplorerHtml(options) {
635
830
  </div>
636
831
 
637
832
  </div>
833
+ </div><!-- content-main -->
834
+
835
+ <!-- Summary Panel -->
836
+ <div id="summary-panel" class="hidden">
837
+ <div class="summary-header">
838
+ <span class="summary-header-title">Request Summary</span>
839
+ <button id="summary-copy-btn" class="summary-copy-btn hidden" title="Copy summary">
840
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
841
+ <span id="summary-copy-label">Copy</span>
842
+ </button>
843
+ </div>
844
+ <div id="summary-body">
845
+ <div class="summary-empty" id="summary-empty">
846
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
847
+ <p>Select a route to see the request summary</p>
848
+ </div>
849
+ <div id="summary-content" class="hidden" style="display:flex;flex-direction:column;gap:14px"></div>
850
+ </div>
638
851
  </div>
639
- </div>
640
- </div>
852
+
853
+ </div><!-- content -->
854
+ </div><!-- main -->
855
+ </div><!-- app -->
641
856
 
642
857
  <script>
643
858
  const CONFIG = ${config};
@@ -648,7 +863,8 @@ const S = {
648
863
  pathParams: {}, queryParams: [], reqHeaders: [],
649
864
  auth: { type: 'none', bearerToken: '', basicUsername: '', basicPassword: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' },
650
865
  body: { type: 'none', content: '' },
651
- response: null, reqTab: 'params', resTab: 'body', loading: false, _uid: 0,
866
+ response: null, reqTab: 'params', resTab: 'body', loading: false, _uid: 0, collapsedGroups: new Set(),
867
+ globalAuth: { type: 'none', bearerToken: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' },
652
868
  };
653
869
  function uid() { return ++S._uid; }
654
870
 
@@ -685,6 +901,7 @@ function init() {
685
901
  if (isLocal) el.envTag.classList.add('dev');
686
902
  bindEvents();
687
903
  loadRoutes();
904
+ renderSummary();
688
905
  }
689
906
 
690
907
  // ── Routes ─────────────────────────────────────────────────────────
@@ -709,25 +926,83 @@ function applyFilter() {
709
926
  S.filtered = q ? base.filter(r => r.path.toLowerCase().includes(q) || r.method.toLowerCase().includes(q)) : base;
710
927
  }
711
928
 
929
+ function groupRoutes(routes) {
930
+ const groups = {};
931
+ routes.forEach(r => {
932
+ // Use group from docs file if available, otherwise fall back to first path segment
933
+ const key = r.group || (r.path.split('/').filter(Boolean)[0] || 'general');
934
+ if (!groups[key]) groups[key] = [];
935
+ groups[key].push(r);
936
+ });
937
+ return groups;
938
+ }
939
+
940
+ function renderRouteItem(r) {
941
+ const active = S.selected?.method === r.method && S.selected?.path === r.path ? ' active' : '';
942
+ return \`<div class="route-item\${active}" data-method="\${r.method}" data-path="\${esc(r.path)}">
943
+ <span class="method-badge method-\${r.method}">\${r.method}</span>
944
+ <span class="route-path" title="\${esc(r.path)}">\${esc(r.path)}</span>
945
+ </div>\`;
946
+ }
947
+
712
948
  function renderRouteList() {
713
949
  if (!S.filtered.length) {
714
950
  el.routeList.innerHTML = \`<div class="empty-routes">\${S.routes.length ? 'No routes match your search.' : 'No routes found.'}</div>\`;
715
951
  return;
716
952
  }
717
- el.routeList.innerHTML = \`<div class="route-count">\${S.filtered.length} route\${S.filtered.length !== 1 ? 's' : ''}</div>\` +
718
- S.filtered.map(r => {
719
- const active = S.selected?.method === r.method && S.selected?.path === r.path ? ' active' : '';
720
- return \`<div class="route-item\${active}" data-method="\${r.method}" data-path="\${esc(r.path)}">
721
- <span class="method-badge method-\${r.method}">\${r.method}</span>
722
- <span class="route-path" title="\${esc(r.path)}">\${esc(r.path)}</span>
723
- </div>\`;
724
- }).join('');
953
+
954
+ const searching = el.search.value.trim().length > 0;
955
+ const total = S.filtered.length;
956
+
957
+ // When searching, show flat list; otherwise group by prefix
958
+ if (searching) {
959
+ el.routeList.innerHTML =
960
+ \`<div class="route-count">\${total} result\${total !== 1 ? 's' : ''}</div>\` +
961
+ S.filtered.map(renderRouteItem).join('');
962
+ } else {
963
+ const groups = groupRoutes(S.filtered);
964
+ el.routeList.innerHTML =
965
+ \`<div class="route-count">\${total} route\${total !== 1 ? 's' : ''}</div>\` +
966
+ Object.entries(groups).map(([name, routes]) => {
967
+ const isCollapsed = S.collapsedGroups && S.collapsedGroups.has(name) ? 'collapsed' : '';
968
+ return \`<div class="route-group" data-group="\${esc(name)}">
969
+ <div class="route-group-header" data-toggle="\${esc(name)}">
970
+ <svg class="route-group-chevron \${isCollapsed}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
971
+ <span class="route-group-name">/\${esc(name)}</span>
972
+ <span class="route-group-count">\${routes.length}</span>
973
+ </div>
974
+ <div class="route-group-body \${isCollapsed}">\${routes.map(renderRouteItem).join('')}</div>
975
+ </div>\`;
976
+ }).join('');
977
+ }
978
+
979
+ // Click handlers for route items
725
980
  el.routeList.querySelectorAll('.route-item').forEach(item => {
726
981
  item.addEventListener('click', () => {
727
982
  const route = S.routes.find(r => r.method === item.dataset.method && r.path === item.dataset.path);
728
983
  if (route) selectRoute(route);
729
984
  });
730
985
  });
986
+
987
+ // Collapse/expand group headers
988
+ el.routeList.querySelectorAll('[data-toggle]').forEach(header => {
989
+ header.addEventListener('click', () => {
990
+ const name = header.dataset.toggle;
991
+ if (!S.collapsedGroups) S.collapsedGroups = new Set();
992
+ const group = header.closest('.route-group');
993
+ const body = group.querySelector('.route-group-body');
994
+ const chevron = group.querySelector('.route-group-chevron');
995
+ if (S.collapsedGroups.has(name)) {
996
+ S.collapsedGroups.delete(name);
997
+ body.classList.remove('collapsed');
998
+ chevron.classList.remove('collapsed');
999
+ } else {
1000
+ S.collapsedGroups.add(name);
1001
+ body.classList.add('collapsed');
1002
+ chevron.classList.add('collapsed');
1003
+ }
1004
+ });
1005
+ });
731
1006
  }
732
1007
 
733
1008
  function selectRoute(route) {
@@ -735,7 +1010,7 @@ function selectRoute(route) {
735
1010
  S.pathParams = {};
736
1011
  (route.params || []).forEach(p => { S.pathParams[p] = ''; });
737
1012
  S.response = null;
738
- renderResponse(); renderUrlBar(); renderPathParams(); renderRouteList();
1013
+ renderResponse(); renderUrlBar(); renderPathParams(); renderRouteList(); renderSummary();
739
1014
  el.sendBtn.disabled = false;
740
1015
  el.methodPill.style.visibility = 'visible';
741
1016
 
@@ -745,8 +1020,28 @@ function selectRoute(route) {
745
1020
  document.querySelectorAll('#req-panel-inner .tab-panel').forEach(p => p.classList.remove('active'));
746
1021
  document.getElementById('tab-params').classList.add('active');
747
1022
 
748
- // Auto-switch to Body tab for methods that send a body
1023
+ // Auto-fill body editor from body schema if available
749
1024
  const bodyMethods = ['POST', 'PUT', 'PATCH'];
1025
+ if (bodyMethods.includes(route.method) && route.body?.length) {
1026
+ const template = {};
1027
+ route.body.forEach(f => {
1028
+ if (f.type === 'number') template[f.name] = 0;
1029
+ else if (f.type === 'boolean') template[f.name] = false;
1030
+ else if (f.type === 'array') template[f.name] = [];
1031
+ else if (f.type === 'object') template[f.name] = {};
1032
+ else template[f.name] = '';
1033
+ });
1034
+ S.body.content = JSON.stringify(template, null, 2);
1035
+ S.body.type = 'json';
1036
+ el.bodyEditor.value = S.body.content;
1037
+ // Sync body type radio
1038
+ document.querySelectorAll('[name="body-type"]').forEach(r => {
1039
+ r.checked = r.value === 'json';
1040
+ });
1041
+ el.formatBtn.style.display = '';
1042
+ }
1043
+
1044
+ // Auto-switch to Body tab for methods that send a body
750
1045
  if (bodyMethods.includes(route.method) && !route.params.length) {
751
1046
  el.reqTabBar.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
752
1047
  el.reqTabBar.querySelector('[data-tab="body"]').classList.add('active');
@@ -764,8 +1059,13 @@ function buildUrl() {
764
1059
  Object.entries(S.pathParams).forEach(([k, v]) => {
765
1060
  path = path.replace(':' + k, encodeURIComponent(v || (':' + k)));
766
1061
  });
767
- const qp = [...S.queryParams.filter(p => p.enabled && p.key).map(p => [p.key, p.value]),
768
- ...(S.auth.type === 'apikey' && S.auth.apiKeyIn === 'query' && S.auth.apiKeyValue ? [[S.auth.apiKeyName, S.auth.apiKeyValue]] : [])];
1062
+ // Per-route apikey in query (overrides global if set)
1063
+ const perRouteQpAuth = S.auth.type === 'apikey' && S.auth.apiKeyIn === 'query' && S.auth.apiKeyValue
1064
+ ? [[S.auth.apiKeyName, S.auth.apiKeyValue]] : [];
1065
+ // Global apikey in query (only if per-route auth is 'none')
1066
+ const globalQpAuth = S.auth.type === 'none' && S.globalAuth.type === 'apikey' && S.globalAuth.apiKeyIn === 'query' && S.globalAuth.apiKeyValue
1067
+ ? [[S.globalAuth.apiKeyName, S.globalAuth.apiKeyValue]] : [];
1068
+ const qp = [...S.queryParams.filter(p => p.enabled && p.key).map(p => [p.key, p.value]), ...perRouteQpAuth, ...globalQpAuth];
769
1069
  const qs = qp.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
770
1070
  return base + path + (qs ? '?' + qs : '');
771
1071
  }
@@ -788,7 +1088,7 @@ function renderPathParams() {
788
1088
  <input class="kv-input" data-path-param="\${esc(p)}" placeholder="value" value="\${esc(S.pathParams[p] || '')}" spellcheck="false" />
789
1089
  </div>\`).join('');
790
1090
  el.pathParamsRows.querySelectorAll('[data-path-param]').forEach(input => {
791
- input.addEventListener('input', () => { S.pathParams[input.dataset.pathParam] = input.value; renderUrlBar(); });
1091
+ input.addEventListener('input', () => { S.pathParams[input.dataset.pathParam] = input.value; renderUrlBar(); renderSummary(); });
792
1092
  });
793
1093
  }
794
1094
 
@@ -840,6 +1140,14 @@ async function sendRequest() {
840
1140
 
841
1141
  const headers = {};
842
1142
  S.reqHeaders.filter(h => h.enabled && h.key).forEach(h => { headers[h.key] = h.value; });
1143
+
1144
+ // Apply global auth first (lower priority)
1145
+ if (S.globalAuth.type === 'bearer' && S.globalAuth.bearerToken)
1146
+ headers['Authorization'] = 'Bearer ' + S.globalAuth.bearerToken;
1147
+ else if (S.globalAuth.type === 'apikey' && S.globalAuth.apiKeyIn === 'header' && S.globalAuth.apiKeyValue)
1148
+ headers[S.globalAuth.apiKeyName || 'X-API-Key'] = S.globalAuth.apiKeyValue;
1149
+
1150
+ // Per-route auth overrides global (higher priority)
843
1151
  if (S.auth.type === 'bearer' && S.auth.bearerToken) headers['Authorization'] = 'Bearer ' + S.auth.bearerToken;
844
1152
  else if (S.auth.type === 'basic') headers['Authorization'] = 'Basic ' + btoa(S.auth.basicUsername + ':' + S.auth.basicPassword);
845
1153
  else if (S.auth.type === 'apikey' && S.auth.apiKeyIn === 'header' && S.auth.apiKeyValue) headers[S.auth.apiKeyName || 'X-API-Key'] = S.auth.apiKeyValue;
@@ -913,6 +1221,219 @@ function renderResponse() {
913
1221
  ).join('');
914
1222
  }
915
1223
 
1224
+ // ── Summary Panel ──────────────────────────────────────────────────
1225
+ function renderSummary() {
1226
+ const panel = $('summary-panel');
1227
+ const empty = $('summary-empty');
1228
+ const content = $('summary-content');
1229
+
1230
+ const copyBtn = $('summary-copy-btn');
1231
+
1232
+ if (!S.selected) {
1233
+ panel.classList.remove('hidden');
1234
+ empty.style.display = 'flex';
1235
+ content.classList.add('hidden');
1236
+ copyBtn.classList.add('hidden');
1237
+ return;
1238
+ }
1239
+
1240
+ panel.classList.remove('hidden');
1241
+ empty.style.display = 'none';
1242
+ content.classList.remove('hidden');
1243
+ content.style.display = 'flex';
1244
+ copyBtn.classList.remove('hidden');
1245
+
1246
+ const method = S.selected.method;
1247
+ const fullUrl = buildUrl();
1248
+
1249
+ // Highlight path params in URL
1250
+ let urlHtml = esc(fullUrl);
1251
+ (S.selected.params || []).forEach(p => {
1252
+ const val = S.pathParams[p];
1253
+ if (val) urlHtml = urlHtml.replace(esc(encodeURIComponent(val)), \`<span class="url-path-param">\${esc(val)}</span>\`);
1254
+ });
1255
+
1256
+ // Active query params
1257
+ const activeQuery = S.queryParams.filter(p => p.enabled && p.key);
1258
+
1259
+ // Active headers (custom + auth)
1260
+ const activeHeaders = S.reqHeaders.filter(h => h.enabled && h.key);
1261
+
1262
+ // Auth being used
1263
+ const effectiveAuth = S.auth.type !== 'none' ? S.auth : S.globalAuth;
1264
+ let authHtml = '';
1265
+ if (effectiveAuth.type === 'bearer') {
1266
+ const tok = effectiveAuth.bearerToken;
1267
+ authHtml = \`<div class="summary-auth-row">
1268
+ <svg class="summary-auth-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
1269
+ <span class="summary-auth-label">Bearer</span>
1270
+ <span class="summary-auth-val">\${tok ? tok.slice(0,24) + (tok.length > 24 ? '…' : '') : '<em>no token</em>'}</span>
1271
+ \${S.auth.type === 'none' ? '<span style="font-size:9px;color:var(--text-dim);margin-left:auto">global</span>' : ''}
1272
+ </div>\`;
1273
+ } else if (effectiveAuth.type === 'basic') {
1274
+ authHtml = \`<div class="summary-auth-row">
1275
+ <svg class="summary-auth-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
1276
+ <span class="summary-auth-label">Basic</span>
1277
+ <span class="summary-auth-val">\${esc(effectiveAuth.basicUsername || '—')}</span>
1278
+ </div>\`;
1279
+ } else if (effectiveAuth.type === 'apikey') {
1280
+ authHtml = \`<div class="summary-auth-row">
1281
+ <svg class="summary-auth-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
1282
+ <span class="summary-auth-label">\${esc(effectiveAuth.apiKeyName)}</span>
1283
+ <span class="summary-auth-val">\${effectiveAuth.apiKeyIn === 'query' ? '(query)' : '(header)'}</span>
1284
+ \${S.auth.type === 'none' && S.globalAuth.type !== 'none' ? '<span style="font-size:9px;color:var(--text-dim);margin-left:auto">global</span>' : ''}
1285
+ </div>\`;
1286
+ } else {
1287
+ authHtml = '<span class="summary-none">No auth</span>';
1288
+ }
1289
+
1290
+ // Body
1291
+ let bodyHtml = '';
1292
+ if (['POST','PUT','PATCH'].includes(method) && S.body.type !== 'none' && S.body.content) {
1293
+ const preview = S.body.content.slice(0, 200) + (S.body.content.length > 200 ? '…' : '');
1294
+ bodyHtml = \`<div class="summary-section">
1295
+ <div class="summary-section-label">Body <span style="font-weight:400;text-transform:none;color:var(--text-dim)">\${S.body.type}</span></div>
1296
+ <div class="summary-body-preview">\${esc(preview)}</div>
1297
+ </div>\`;
1298
+ }
1299
+
1300
+ content.innerHTML = \`
1301
+ <!-- URL -->
1302
+ <div class="summary-section">
1303
+ <div class="summary-section-label">
1304
+ <span class="summary-method-pill method-\${method}" style="border-color:inherit">\${method}</span>
1305
+ Endpoint
1306
+ </div>
1307
+ <div class="summary-url">\${urlHtml}</div>
1308
+ \${S.selected.description ? \`<div style="font-size:11px;color:var(--text-muted);margin-top:5px;line-height:1.5">\${esc(S.selected.description)}</div>\` : ''}
1309
+ </div>
1310
+
1311
+ <!-- Path params -->
1312
+ \${(S.selected.params || []).length ? \`<div class="summary-section">
1313
+ <div class="summary-section-label">Path Params</div>
1314
+ \${S.selected.params.map(p => \`<div class="summary-row">
1315
+ <span class="summary-key">:\${esc(p)}</span>
1316
+ <span class="summary-val">\${S.pathParams[p] ? esc(S.pathParams[p]) : '<em style="color:var(--del-fg)">empty</em>'}</span>
1317
+ </div>\`).join('')}
1318
+ </div>\` : ''}
1319
+
1320
+ <!-- Query params -->
1321
+ <div class="summary-section">
1322
+ <div class="summary-section-label">Query Params</div>
1323
+ \${activeQuery.length
1324
+ ? activeQuery.map(p => \`<div class="summary-row">
1325
+ <span class="summary-key">\${esc(p.key)}</span>
1326
+ <span class="summary-val">\${esc(p.value)}</span>
1327
+ </div>\`).join('')
1328
+ : '<span class="summary-none">None</span>'}
1329
+ </div>
1330
+
1331
+ <!-- Headers -->
1332
+ <div class="summary-section">
1333
+ <div class="summary-section-label">Headers</div>
1334
+ \${activeHeaders.length
1335
+ ? activeHeaders.map(h => \`<div class="summary-row">
1336
+ <span class="summary-key">\${esc(h.key)}</span>
1337
+ <span class="summary-val">\${esc(h.value)}</span>
1338
+ </div>\`).join('')
1339
+ : '<span class="summary-none">None</span>'}
1340
+ </div>
1341
+
1342
+ <!-- Auth -->
1343
+ <div class="summary-section">
1344
+ <div class="summary-section-label">Auth</div>
1345
+ \${authHtml}
1346
+ </div>
1347
+
1348
+ \${bodyHtml}
1349
+
1350
+ \${renderDocFields('Body Schema', S.selected.body)}
1351
+ \${renderDocFields('Query Params', S.selected.query)}
1352
+ \${renderDocFields('Required Headers', S.selected.headers)}
1353
+ \${renderDocFields('Response', S.selected.response)}
1354
+ \`;
1355
+ }
1356
+
1357
+ // ── Copy Summary ───────────────────────────────────────────────────
1358
+ function copySummary() {
1359
+ if (!S.selected) return;
1360
+ const r = S.selected;
1361
+ const lines = [];
1362
+
1363
+ lines.push(\`\${r.method} \${buildUrl()}\`);
1364
+ if (r.description) lines.push(\`\`, ...r.description.split('\\n').map(l => \`# \${l}\`));
1365
+
1366
+ if (r.params?.length) {
1367
+ lines.push(\`\`, \`[Path Params]\`);
1368
+ r.params.forEach(p => lines.push(\` \${p}: \${S.pathParams[p] || ''}\`));
1369
+ }
1370
+
1371
+ const activeQuery = S.queryParams.filter(p => p.enabled && p.key);
1372
+ if (activeQuery.length) {
1373
+ lines.push(\`\`, \`[Query Params]\`);
1374
+ activeQuery.forEach(p => lines.push(\` \${p.key}: \${p.value}\`));
1375
+ }
1376
+
1377
+ if (r.query?.length) {
1378
+ lines.push(\`\`, \`[Query Schema]\`);
1379
+ r.query.forEach(f => lines.push(\` \${f.name}: \${f.type}\${f.required ? ' (required)' : ''}\${f.rules?.length ? ' | ' + f.rules.join(', ') : ''}\${f.description ? ' — ' + f.description : ''}\`));
1380
+ }
1381
+
1382
+ const activeHeaders = S.reqHeaders.filter(h => h.enabled && h.key);
1383
+ if (activeHeaders.length) {
1384
+ lines.push(\`\`, \`[Headers]\`);
1385
+ activeHeaders.forEach(h => lines.push(\` \${h.key}: \${h.value}\`));
1386
+ }
1387
+
1388
+ if (r.headers?.length) {
1389
+ lines.push(\`\`, \`[Required Headers]\`);
1390
+ r.headers.forEach(f => lines.push(\` \${f.name}\${f.required ? ' (required)' : ''}\${f.description ? ' — ' + f.description : ''}\`));
1391
+ }
1392
+
1393
+ if (r.body?.length) {
1394
+ lines.push(\`\`, \`[Body Schema]\`);
1395
+ r.body.forEach(f => lines.push(\` \${f.name}: \${f.type}\${f.required ? ' (required)' : ' (optional)'}\${f.rules?.length ? ' | ' + f.rules.join(', ') : ''}\${f.description ? ' — ' + f.description : ''}\`));
1396
+ }
1397
+
1398
+ if (['POST','PUT','PATCH'].includes(r.method) && S.body.type !== 'none' && S.body.content) {
1399
+ lines.push(\`\`, \`[Body Content (\${S.body.type})]\`, S.body.content);
1400
+ }
1401
+
1402
+ if (r.response?.length) {
1403
+ lines.push(\`\`, \`[Response Schema]\`);
1404
+ r.response.forEach(f => lines.push(\` \${f.name}: \${f.type}\${f.description ? ' — ' + f.description : ''}\`));
1405
+ }
1406
+
1407
+ navigator.clipboard.writeText(lines.join('\\n')).then(() => {
1408
+ const btn = $('summary-copy-btn');
1409
+ const label = $('summary-copy-label');
1410
+ btn.classList.add('copied');
1411
+ label.textContent = 'Copied!';
1412
+ setTimeout(() => { btn.classList.remove('copied'); label.textContent = 'Copy'; }, 1800);
1413
+ });
1414
+ }
1415
+
1416
+ // Render a list of DocField items (body/query/headers/response) in the summary panel
1417
+ function renderDocFields(label, fields) {
1418
+ if (!fields || fields.length === 0) return '';
1419
+ return \`<div class="summary-section">
1420
+ <div class="summary-section-label">\${label}</div>
1421
+ \${fields.map(f => {
1422
+ const typeTag = f.type ? \`<span class="df-type">\${esc(f.type)}</span>\` : '';
1423
+ const reqTag = f.required ? '<span class="df-req">required</span>' : '<span class="df-opt">optional</span>';
1424
+ const rulesTxt = f.rules?.length ? \`<span class="df-rules">\${esc(f.rules.join(' · '))}</span>\` : '';
1425
+ const descTxt = f.description ? \`<span class="df-desc">\${esc(f.description)}</span>\` : '';
1426
+ return \`<div class="df-row">
1427
+ <div class="df-top">
1428
+ <span class="df-name">\${esc(f.name)}</span>
1429
+ \${typeTag}\${reqTag}\${rulesTxt}
1430
+ </div>
1431
+ \${descTxt ? \`<div class="df-bottom">\${descTxt}</div>\` : ''}
1432
+ </div>\`;
1433
+ }).join('')}
1434
+ </div>\`;
1435
+ }
1436
+
916
1437
  function syntaxHighlight(json) {
917
1438
  return json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
918
1439
  .replace(/("(?:\\\\u[\\da-fA-F]{4}|\\\\[^u]|[^\\\\\\"])*"(?:\\s*:)?|\\b(?:true|false|null)\\b|-?\\d+(?:\\.\\d+)?(?:[eE][+\\-]?\\d+)?)/g, m => {
@@ -991,9 +1512,76 @@ function initSidebarResize() {
991
1512
  function bindEvents() {
992
1513
  el.reloadBtn.addEventListener('click', loadRoutes);
993
1514
  el.search.addEventListener('input', () => { applyFilter(); renderRouteList(); });
994
- el.baseUrl.addEventListener('input', renderUrlBar);
1515
+
1516
+ // ── Global Auth ──
1517
+ const gauthBtn = $('global-auth-btn');
1518
+ const gauthDrop = $('global-auth-dropdown');
1519
+ const gauthBadge = $('gauth-active-badge');
1520
+ const gauthBearer = $('gauth-bearer-token');
1521
+ const gauthKeyName= $('gauth-apikey-name');
1522
+ const gauthKeyVal = $('gauth-apikey-value');
1523
+ const gauthKeyIn = $('gauth-apikey-in');
1524
+
1525
+ gauthBtn.addEventListener('click', e => {
1526
+ e.stopPropagation();
1527
+ gauthDrop.classList.toggle('hidden');
1528
+ gauthBtn.classList.toggle('active', !gauthDrop.classList.contains('hidden'));
1529
+ });
1530
+ document.addEventListener('click', e => {
1531
+ if (!gauthDrop.contains(e.target) && e.target !== gauthBtn) {
1532
+ gauthDrop.classList.add('hidden');
1533
+ gauthBtn.classList.remove('active');
1534
+ }
1535
+ });
1536
+
1537
+ document.querySelectorAll('.gauth-type-btn').forEach(btn => {
1538
+ btn.addEventListener('click', () => {
1539
+ document.querySelectorAll('.gauth-type-btn').forEach(b => b.classList.remove('active'));
1540
+ btn.classList.add('active');
1541
+ const t = btn.dataset.gauth;
1542
+ ['none','bearer','apikey'].forEach(x => $('gauth-'+x+'-panel').classList.toggle('hidden', x !== t));
1543
+ });
1544
+ });
1545
+
1546
+ $('gauth-save-btn').addEventListener('click', () => {
1547
+ const activeBtn = document.querySelector('.gauth-type-btn.active');
1548
+ const type = activeBtn ? activeBtn.dataset.gauth : 'none';
1549
+ S.globalAuth = {
1550
+ type,
1551
+ bearerToken: gauthBearer.value,
1552
+ apiKeyName: gauthKeyName.value || 'X-API-Key',
1553
+ apiKeyValue: gauthKeyVal.value,
1554
+ apiKeyIn: gauthKeyIn.value,
1555
+ };
1556
+ const isActive = type !== 'none' && (S.globalAuth.bearerToken || S.globalAuth.apiKeyValue);
1557
+ gauthBadge.classList.toggle('hidden', !isActive);
1558
+ gauthBtn.classList.toggle('active', isActive);
1559
+ // Show dot indicator
1560
+ const dot = gauthBtn.querySelector('.dot');
1561
+ if (isActive && !dot) {
1562
+ const d = document.createElement('span'); d.className = 'dot';
1563
+ gauthBtn.appendChild(d);
1564
+ } else if (!isActive && dot) dot.remove();
1565
+ gauthDrop.classList.add('hidden');
1566
+ renderUrlBar(); renderSummary();
1567
+ });
1568
+
1569
+ $('gauth-clear-btn').addEventListener('click', () => {
1570
+ S.globalAuth = { type: 'none', bearerToken: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' };
1571
+ document.querySelectorAll('.gauth-type-btn').forEach(b => b.classList.remove('active'));
1572
+ document.querySelector('[data-gauth="none"]').classList.add('active');
1573
+ ['none','bearer','apikey'].forEach(x => $('gauth-'+x+'-panel').classList.toggle('hidden', x !== 'none'));
1574
+ gauthBearer.value = ''; gauthKeyVal.value = '';
1575
+ gauthBadge.classList.add('hidden');
1576
+ gauthBtn.classList.remove('active');
1577
+ gauthBtn.querySelector('.dot')?.remove();
1578
+ renderUrlBar();
1579
+ });
1580
+ el.baseUrl.addEventListener('input', () => { renderUrlBar(); renderSummary(); });
995
1581
  el.sendBtn.addEventListener('click', sendRequest);
996
1582
 
1583
+ $('summary-copy-btn').addEventListener('click', copySummary);
1584
+
997
1585
  el.addQueryBtn.addEventListener('click', () => {
998
1586
  S.queryParams.push({ id: uid(), key: '', value: '', enabled: true });
999
1587
  renderQueryParams();
@@ -1029,7 +1617,7 @@ function bindEvents() {
1029
1617
  el.formatBtn.style.display = S.body.type === 'json' ? '' : 'none';
1030
1618
  });
1031
1619
  });
1032
- el.bodyEditor.addEventListener('input', () => { S.body.content = el.bodyEditor.value; });
1620
+ el.bodyEditor.addEventListener('input', () => { S.body.content = el.bodyEditor.value; renderSummary(); });
1033
1621
  el.formatBtn.addEventListener('click', () => {
1034
1622
  try {
1035
1623
  el.bodyEditor.value = JSON.stringify(JSON.parse(el.bodyEditor.value), null, 2);