@vnphu/nestjs-api-explorer 0.1.0 → 0.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.
@@ -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,74 @@ 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: 240px; 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-body { flex: 1; overflow-y: auto; padding: 10px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
267
+ .summary-section { display: flex; flex-direction: column; gap: 6px; }
268
+ .summary-section-label {
269
+ font-size: 10px; font-weight: 700; color: var(--text-dim);
270
+ text-transform: uppercase; letter-spacing: 0.7px;
271
+ }
272
+ .summary-url {
273
+ font-family: var(--font-mono); font-size: 11px; color: var(--text);
274
+ word-break: break-all; line-height: 1.6; background: var(--bg-input);
275
+ border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px;
276
+ }
277
+ .summary-url .url-path-param { color: var(--put-fg); font-weight: 600; }
278
+ .summary-row {
279
+ display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
280
+ font-size: 11px; padding: 3px 0; border-bottom: 1px dashed var(--border);
281
+ }
282
+ .summary-row:last-child { border-bottom: none; }
283
+ .summary-key { font-family: var(--font-mono); color: var(--accent); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
284
+ .summary-val { font-family: var(--font-mono); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
285
+ .summary-none { font-size: 11px; color: var(--text-dim); font-style: italic; }
286
+ .summary-method-pill {
287
+ display: inline-block; font-family: var(--font-mono); font-size: 10px; font-weight: 700;
288
+ padding: 2px 7px; border-radius: 4px; margin-right: 6px; vertical-align: middle;
289
+ border-width: 1px; border-style: solid;
290
+ }
291
+ .summary-auth-row {
292
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
293
+ padding: 4px 8px; background: var(--bg-input); border-radius: var(--radius-sm);
294
+ border: 1px solid var(--border);
295
+ }
296
+ .summary-auth-icon { color: var(--accent); flex-shrink: 0; }
297
+ .summary-auth-label { color: var(--text-muted); }
298
+ .summary-auth-val { font-family: var(--font-mono); color: var(--text); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
299
+ .summary-body-preview {
300
+ font-family: var(--font-mono); font-size: 11px; color: var(--text-muted);
301
+ background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm);
302
+ padding: 6px 8px; max-height: 100px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5;
303
+ }
304
+ .summary-empty {
305
+ flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
306
+ gap: 8px; padding: 20px; color: var(--text-dim); text-align: center;
307
+ }
308
+ .summary-empty p { font-size: 11px; line-height: 1.6; }
309
+ /* ── DocField rows (body/query/headers/response from docs file) ── */
310
+ .df-row { padding: 5px 0; border-bottom: 1px dashed var(--border); }
311
+ .df-row:last-child { border-bottom: none; }
312
+ .df-top { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
313
+ .df-bottom { margin-top: 2px; }
314
+ .df-name { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--accent); }
315
+ .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); }
316
+ .df-req { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; background: #fef2f2; border: 1px solid #fecaca; color: var(--del-fg); }
317
+ .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); }
318
+ .df-rules { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
319
+ .df-desc { font-size: 10px; color: var(--text-muted); font-style: italic; }
181
320
 
182
321
  /* ── URL bar ── */
183
322
  #url-bar {
@@ -462,7 +601,13 @@ function getExplorerHtml(options) {
462
601
  <span class="base-url-label">Base URL</span>
463
602
  <input id="base-url" type="url" spellcheck="false" />
464
603
  </div>
465
- <button class="icon-btn" id="reload-btn" title="Reload routes (r)">
604
+ <button class="icon-btn" id="global-auth-btn" title="Global Authentication">
605
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
606
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
607
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
608
+ </svg>
609
+ </button>
610
+ <button class="icon-btn" id="reload-btn" title="Reload routes">
466
611
  <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
612
  <polyline points="23 4 23 10 17 10"/>
468
613
  <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
@@ -470,6 +615,48 @@ function getExplorerHtml(options) {
470
615
  </button>
471
616
  </header>
472
617
 
618
+ <!-- Global Auth Dropdown -->
619
+ <div id="global-auth-dropdown" class="hidden">
620
+ <div class="gauth-title">
621
+ <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>
622
+ Global Authentication
623
+ <span class="gauth-active-badge hidden" id="gauth-active-badge">Active</span>
624
+ </div>
625
+ <div class="gauth-type-row">
626
+ <button class="gauth-type-btn active" data-gauth="none">None</button>
627
+ <button class="gauth-type-btn" data-gauth="bearer">Bearer Token</button>
628
+ <button class="gauth-type-btn" data-gauth="apikey">API Key</button>
629
+ </div>
630
+ <div id="gauth-none-panel" class="text-muted" style="font-size:12px">No global auth. Per-request auth (Auth tab) still applies.</div>
631
+ <div id="gauth-bearer-panel" class="hidden flex-col gap-6">
632
+ <div class="field-group">
633
+ <label class="field-label">Bearer Token</label>
634
+ <input class="kv-input" id="gauth-bearer-token" type="text" style="width:100%" placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…" spellcheck="false" autocomplete="off" />
635
+ </div>
636
+ </div>
637
+ <div id="gauth-apikey-panel" class="hidden flex-col gap-6">
638
+ <div class="field-group">
639
+ <label class="field-label">Key Name</label>
640
+ <input class="kv-input" id="gauth-apikey-name" type="text" style="width:100%" placeholder="X-API-Key" value="X-API-Key" spellcheck="false" />
641
+ </div>
642
+ <div class="field-group">
643
+ <label class="field-label">Key Value</label>
644
+ <input class="kv-input" id="gauth-apikey-value" type="text" style="width:100%" placeholder="your-secret-key" autocomplete="off" spellcheck="false" />
645
+ </div>
646
+ <div class="field-group">
647
+ <label class="field-label">Add To</label>
648
+ <select class="styled-select" id="gauth-apikey-in" style="width:160px">
649
+ <option value="header">Header</option>
650
+ <option value="query">Query String</option>
651
+ </select>
652
+ </div>
653
+ </div>
654
+ <div class="gauth-footer">
655
+ <button class="gauth-save-btn" id="gauth-save-btn">Apply Globally</button>
656
+ <button class="gauth-clear-btn" id="gauth-clear-btn">Clear</button>
657
+ </div>
658
+ </div>
659
+
473
660
  <!-- Main -->
474
661
  <div id="main">
475
662
 
@@ -491,6 +678,7 @@ function getExplorerHtml(options) {
491
678
 
492
679
  <!-- Content -->
493
680
  <div id="content">
681
+ <div id="content-main">
494
682
 
495
683
  <!-- URL bar -->
496
684
  <div id="url-bar">
@@ -635,9 +823,25 @@ function getExplorerHtml(options) {
635
823
  </div>
636
824
 
637
825
  </div>
826
+ </div><!-- content-main -->
827
+
828
+ <!-- Summary Panel -->
829
+ <div id="summary-panel" class="hidden">
830
+ <div class="summary-header">
831
+ <span class="summary-header-title">Request Summary</span>
832
+ </div>
833
+ <div id="summary-body">
834
+ <div class="summary-empty" id="summary-empty">
835
+ <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>
836
+ <p>Select a route to see the request summary</p>
837
+ </div>
838
+ <div id="summary-content" class="hidden" style="display:flex;flex-direction:column;gap:14px"></div>
839
+ </div>
638
840
  </div>
639
- </div>
640
- </div>
841
+
842
+ </div><!-- content -->
843
+ </div><!-- main -->
844
+ </div><!-- app -->
641
845
 
642
846
  <script>
643
847
  const CONFIG = ${config};
@@ -648,7 +852,8 @@ const S = {
648
852
  pathParams: {}, queryParams: [], reqHeaders: [],
649
853
  auth: { type: 'none', bearerToken: '', basicUsername: '', basicPassword: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' },
650
854
  body: { type: 'none', content: '' },
651
- response: null, reqTab: 'params', resTab: 'body', loading: false, _uid: 0,
855
+ response: null, reqTab: 'params', resTab: 'body', loading: false, _uid: 0, collapsedGroups: new Set(),
856
+ globalAuth: { type: 'none', bearerToken: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' },
652
857
  };
653
858
  function uid() { return ++S._uid; }
654
859
 
@@ -685,6 +890,7 @@ function init() {
685
890
  if (isLocal) el.envTag.classList.add('dev');
686
891
  bindEvents();
687
892
  loadRoutes();
893
+ renderSummary();
688
894
  }
689
895
 
690
896
  // ── Routes ─────────────────────────────────────────────────────────
@@ -709,25 +915,85 @@ function applyFilter() {
709
915
  S.filtered = q ? base.filter(r => r.path.toLowerCase().includes(q) || r.method.toLowerCase().includes(q)) : base;
710
916
  }
711
917
 
918
+ function groupRoutes(routes) {
919
+ const groups = {};
920
+ routes.forEach(r => {
921
+ // Use group from docs file if available, otherwise fall back to first path segment
922
+ const key = r.group || (r.path.split('/').filter(Boolean)[0] || 'general');
923
+ if (!groups[key]) groups[key] = [];
924
+ groups[key].push(r);
925
+ });
926
+ return groups;
927
+ }
928
+
929
+ function renderRouteItem(r) {
930
+ const active = S.selected?.method === r.method && S.selected?.path === r.path ? ' active' : '';
931
+ const desc = r.description ? \`<span class="route-desc">\${esc(r.description.split('\\n')[0])}</span>\` : '';
932
+ return \`<div class="route-item\${active}" data-method="\${r.method}" data-path="\${esc(r.path)}">
933
+ <span class="method-badge method-\${r.method}">\${r.method}</span>
934
+ <span class="route-path" title="\${esc(r.path)}">\${esc(r.path)}</span>
935
+ \${desc}
936
+ </div>\`;
937
+ }
938
+
712
939
  function renderRouteList() {
713
940
  if (!S.filtered.length) {
714
941
  el.routeList.innerHTML = \`<div class="empty-routes">\${S.routes.length ? 'No routes match your search.' : 'No routes found.'}</div>\`;
715
942
  return;
716
943
  }
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('');
944
+
945
+ const searching = el.search.value.trim().length > 0;
946
+ const total = S.filtered.length;
947
+
948
+ // When searching, show flat list; otherwise group by prefix
949
+ if (searching) {
950
+ el.routeList.innerHTML =
951
+ \`<div class="route-count">\${total} result\${total !== 1 ? 's' : ''}</div>\` +
952
+ S.filtered.map(renderRouteItem).join('');
953
+ } else {
954
+ const groups = groupRoutes(S.filtered);
955
+ el.routeList.innerHTML =
956
+ \`<div class="route-count">\${total} route\${total !== 1 ? 's' : ''}</div>\` +
957
+ Object.entries(groups).map(([name, routes]) => {
958
+ const isCollapsed = S.collapsedGroups && S.collapsedGroups.has(name) ? 'collapsed' : '';
959
+ return \`<div class="route-group" data-group="\${esc(name)}">
960
+ <div class="route-group-header" data-toggle="\${esc(name)}">
961
+ <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>
962
+ <span class="route-group-name">/\${esc(name)}</span>
963
+ <span class="route-group-count">\${routes.length}</span>
964
+ </div>
965
+ <div class="route-group-body \${isCollapsed}">\${routes.map(renderRouteItem).join('')}</div>
966
+ </div>\`;
967
+ }).join('');
968
+ }
969
+
970
+ // Click handlers for route items
725
971
  el.routeList.querySelectorAll('.route-item').forEach(item => {
726
972
  item.addEventListener('click', () => {
727
973
  const route = S.routes.find(r => r.method === item.dataset.method && r.path === item.dataset.path);
728
974
  if (route) selectRoute(route);
729
975
  });
730
976
  });
977
+
978
+ // Collapse/expand group headers
979
+ el.routeList.querySelectorAll('[data-toggle]').forEach(header => {
980
+ header.addEventListener('click', () => {
981
+ const name = header.dataset.toggle;
982
+ if (!S.collapsedGroups) S.collapsedGroups = new Set();
983
+ const group = header.closest('.route-group');
984
+ const body = group.querySelector('.route-group-body');
985
+ const chevron = group.querySelector('.route-group-chevron');
986
+ if (S.collapsedGroups.has(name)) {
987
+ S.collapsedGroups.delete(name);
988
+ body.classList.remove('collapsed');
989
+ chevron.classList.remove('collapsed');
990
+ } else {
991
+ S.collapsedGroups.add(name);
992
+ body.classList.add('collapsed');
993
+ chevron.classList.add('collapsed');
994
+ }
995
+ });
996
+ });
731
997
  }
732
998
 
733
999
  function selectRoute(route) {
@@ -735,7 +1001,7 @@ function selectRoute(route) {
735
1001
  S.pathParams = {};
736
1002
  (route.params || []).forEach(p => { S.pathParams[p] = ''; });
737
1003
  S.response = null;
738
- renderResponse(); renderUrlBar(); renderPathParams(); renderRouteList();
1004
+ renderResponse(); renderUrlBar(); renderPathParams(); renderRouteList(); renderSummary();
739
1005
  el.sendBtn.disabled = false;
740
1006
  el.methodPill.style.visibility = 'visible';
741
1007
 
@@ -764,8 +1030,13 @@ function buildUrl() {
764
1030
  Object.entries(S.pathParams).forEach(([k, v]) => {
765
1031
  path = path.replace(':' + k, encodeURIComponent(v || (':' + k)));
766
1032
  });
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]] : [])];
1033
+ // Per-route apikey in query (overrides global if set)
1034
+ const perRouteQpAuth = S.auth.type === 'apikey' && S.auth.apiKeyIn === 'query' && S.auth.apiKeyValue
1035
+ ? [[S.auth.apiKeyName, S.auth.apiKeyValue]] : [];
1036
+ // Global apikey in query (only if per-route auth is 'none')
1037
+ const globalQpAuth = S.auth.type === 'none' && S.globalAuth.type === 'apikey' && S.globalAuth.apiKeyIn === 'query' && S.globalAuth.apiKeyValue
1038
+ ? [[S.globalAuth.apiKeyName, S.globalAuth.apiKeyValue]] : [];
1039
+ const qp = [...S.queryParams.filter(p => p.enabled && p.key).map(p => [p.key, p.value]), ...perRouteQpAuth, ...globalQpAuth];
769
1040
  const qs = qp.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
770
1041
  return base + path + (qs ? '?' + qs : '');
771
1042
  }
@@ -788,7 +1059,7 @@ function renderPathParams() {
788
1059
  <input class="kv-input" data-path-param="\${esc(p)}" placeholder="value" value="\${esc(S.pathParams[p] || '')}" spellcheck="false" />
789
1060
  </div>\`).join('');
790
1061
  el.pathParamsRows.querySelectorAll('[data-path-param]').forEach(input => {
791
- input.addEventListener('input', () => { S.pathParams[input.dataset.pathParam] = input.value; renderUrlBar(); });
1062
+ input.addEventListener('input', () => { S.pathParams[input.dataset.pathParam] = input.value; renderUrlBar(); renderSummary(); });
792
1063
  });
793
1064
  }
794
1065
 
@@ -840,6 +1111,14 @@ async function sendRequest() {
840
1111
 
841
1112
  const headers = {};
842
1113
  S.reqHeaders.filter(h => h.enabled && h.key).forEach(h => { headers[h.key] = h.value; });
1114
+
1115
+ // Apply global auth first (lower priority)
1116
+ if (S.globalAuth.type === 'bearer' && S.globalAuth.bearerToken)
1117
+ headers['Authorization'] = 'Bearer ' + S.globalAuth.bearerToken;
1118
+ else if (S.globalAuth.type === 'apikey' && S.globalAuth.apiKeyIn === 'header' && S.globalAuth.apiKeyValue)
1119
+ headers[S.globalAuth.apiKeyName || 'X-API-Key'] = S.globalAuth.apiKeyValue;
1120
+
1121
+ // Per-route auth overrides global (higher priority)
843
1122
  if (S.auth.type === 'bearer' && S.auth.bearerToken) headers['Authorization'] = 'Bearer ' + S.auth.bearerToken;
844
1123
  else if (S.auth.type === 'basic') headers['Authorization'] = 'Basic ' + btoa(S.auth.basicUsername + ':' + S.auth.basicPassword);
845
1124
  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 +1192,155 @@ function renderResponse() {
913
1192
  ).join('');
914
1193
  }
915
1194
 
1195
+ // ── Summary Panel ──────────────────────────────────────────────────
1196
+ function renderSummary() {
1197
+ const panel = $('summary-panel');
1198
+ const empty = $('summary-empty');
1199
+ const content = $('summary-content');
1200
+
1201
+ if (!S.selected) {
1202
+ panel.classList.remove('hidden');
1203
+ empty.style.display = 'flex';
1204
+ content.style.display = 'none';
1205
+ return;
1206
+ }
1207
+
1208
+ panel.classList.remove('hidden');
1209
+ empty.style.display = 'none';
1210
+ content.style.display = 'flex';
1211
+
1212
+ const method = S.selected.method;
1213
+ const fullUrl = buildUrl();
1214
+
1215
+ // Highlight path params in URL
1216
+ let urlHtml = esc(fullUrl);
1217
+ (S.selected.params || []).forEach(p => {
1218
+ const val = S.pathParams[p];
1219
+ if (val) urlHtml = urlHtml.replace(esc(encodeURIComponent(val)), \`<span class="url-path-param">\${esc(val)}</span>\`);
1220
+ });
1221
+
1222
+ // Active query params
1223
+ const activeQuery = S.queryParams.filter(p => p.enabled && p.key);
1224
+
1225
+ // Active headers (custom + auth)
1226
+ const activeHeaders = S.reqHeaders.filter(h => h.enabled && h.key);
1227
+
1228
+ // Auth being used
1229
+ const effectiveAuth = S.auth.type !== 'none' ? S.auth : S.globalAuth;
1230
+ let authHtml = '';
1231
+ if (effectiveAuth.type === 'bearer') {
1232
+ const tok = effectiveAuth.bearerToken;
1233
+ authHtml = \`<div class="summary-auth-row">
1234
+ <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>
1235
+ <span class="summary-auth-label">Bearer</span>
1236
+ <span class="summary-auth-val">\${tok ? tok.slice(0,24) + (tok.length > 24 ? '…' : '') : '<em>no token</em>'}</span>
1237
+ \${S.auth.type === 'none' ? '<span style="font-size:9px;color:var(--text-dim);margin-left:auto">global</span>' : ''}
1238
+ </div>\`;
1239
+ } else if (effectiveAuth.type === 'basic') {
1240
+ authHtml = \`<div class="summary-auth-row">
1241
+ <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>
1242
+ <span class="summary-auth-label">Basic</span>
1243
+ <span class="summary-auth-val">\${esc(effectiveAuth.basicUsername || '—')}</span>
1244
+ </div>\`;
1245
+ } else if (effectiveAuth.type === 'apikey') {
1246
+ authHtml = \`<div class="summary-auth-row">
1247
+ <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>
1248
+ <span class="summary-auth-label">\${esc(effectiveAuth.apiKeyName)}</span>
1249
+ <span class="summary-auth-val">\${effectiveAuth.apiKeyIn === 'query' ? '(query)' : '(header)'}</span>
1250
+ \${S.auth.type === 'none' && S.globalAuth.type !== 'none' ? '<span style="font-size:9px;color:var(--text-dim);margin-left:auto">global</span>' : ''}
1251
+ </div>\`;
1252
+ } else {
1253
+ authHtml = '<span class="summary-none">No auth</span>';
1254
+ }
1255
+
1256
+ // Body
1257
+ let bodyHtml = '';
1258
+ if (['POST','PUT','PATCH'].includes(method) && S.body.type !== 'none' && S.body.content) {
1259
+ const preview = S.body.content.slice(0, 200) + (S.body.content.length > 200 ? '…' : '');
1260
+ bodyHtml = \`<div class="summary-section">
1261
+ <div class="summary-section-label">Body <span style="font-weight:400;text-transform:none;color:var(--text-dim)">\${S.body.type}</span></div>
1262
+ <div class="summary-body-preview">\${esc(preview)}</div>
1263
+ </div>\`;
1264
+ }
1265
+
1266
+ content.innerHTML = \`
1267
+ <!-- URL -->
1268
+ <div class="summary-section">
1269
+ <div class="summary-section-label">
1270
+ <span class="summary-method-pill method-\${method}" style="border-color:inherit">\${method}</span>
1271
+ Endpoint
1272
+ </div>
1273
+ <div class="summary-url">\${urlHtml}</div>
1274
+ \${S.selected.description ? \`<div style="font-size:11px;color:var(--text-muted);margin-top:5px;line-height:1.5">\${esc(S.selected.description)}</div>\` : ''}
1275
+ </div>
1276
+
1277
+ <!-- Path params -->
1278
+ \${(S.selected.params || []).length ? \`<div class="summary-section">
1279
+ <div class="summary-section-label">Path Params</div>
1280
+ \${S.selected.params.map(p => \`<div class="summary-row">
1281
+ <span class="summary-key">:\${esc(p)}</span>
1282
+ <span class="summary-val">\${S.pathParams[p] ? esc(S.pathParams[p]) : '<em style="color:var(--del-fg)">empty</em>'}</span>
1283
+ </div>\`).join('')}
1284
+ </div>\` : ''}
1285
+
1286
+ <!-- Query params -->
1287
+ <div class="summary-section">
1288
+ <div class="summary-section-label">Query Params</div>
1289
+ \${activeQuery.length
1290
+ ? activeQuery.map(p => \`<div class="summary-row">
1291
+ <span class="summary-key">\${esc(p.key)}</span>
1292
+ <span class="summary-val">\${esc(p.value)}</span>
1293
+ </div>\`).join('')
1294
+ : '<span class="summary-none">None</span>'}
1295
+ </div>
1296
+
1297
+ <!-- Headers -->
1298
+ <div class="summary-section">
1299
+ <div class="summary-section-label">Headers</div>
1300
+ \${activeHeaders.length
1301
+ ? activeHeaders.map(h => \`<div class="summary-row">
1302
+ <span class="summary-key">\${esc(h.key)}</span>
1303
+ <span class="summary-val">\${esc(h.value)}</span>
1304
+ </div>\`).join('')
1305
+ : '<span class="summary-none">None</span>'}
1306
+ </div>
1307
+
1308
+ <!-- Auth -->
1309
+ <div class="summary-section">
1310
+ <div class="summary-section-label">Auth</div>
1311
+ \${authHtml}
1312
+ </div>
1313
+
1314
+ \${bodyHtml}
1315
+
1316
+ \${renderDocFields('Body Schema', S.selected.body)}
1317
+ \${renderDocFields('Query Params', S.selected.query)}
1318
+ \${renderDocFields('Required Headers', S.selected.headers)}
1319
+ \${renderDocFields('Response', S.selected.response)}
1320
+ \`;
1321
+ }
1322
+
1323
+ // Render a list of DocField items (body/query/headers/response) in the summary panel
1324
+ function renderDocFields(label, fields) {
1325
+ if (!fields || fields.length === 0) return '';
1326
+ return \`<div class="summary-section">
1327
+ <div class="summary-section-label">\${label}</div>
1328
+ \${fields.map(f => {
1329
+ const typeTag = f.type ? \`<span class="df-type">\${esc(f.type)}</span>\` : '';
1330
+ const reqTag = f.required ? '<span class="df-req">required</span>' : '<span class="df-opt">optional</span>';
1331
+ const rulesTxt = f.rules?.length ? \`<span class="df-rules">\${esc(f.rules.join(' · '))}</span>\` : '';
1332
+ const descTxt = f.description ? \`<span class="df-desc">\${esc(f.description)}</span>\` : '';
1333
+ return \`<div class="df-row">
1334
+ <div class="df-top">
1335
+ <span class="df-name">\${esc(f.name)}</span>
1336
+ \${typeTag}\${reqTag}\${rulesTxt}
1337
+ </div>
1338
+ \${descTxt ? \`<div class="df-bottom">\${descTxt}</div>\` : ''}
1339
+ </div>\`;
1340
+ }).join('')}
1341
+ </div>\`;
1342
+ }
1343
+
916
1344
  function syntaxHighlight(json) {
917
1345
  return json.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
918
1346
  .replace(/("(?:\\\\u[\\da-fA-F]{4}|\\\\[^u]|[^\\\\\\"])*"(?:\\s*:)?|\\b(?:true|false|null)\\b|-?\\d+(?:\\.\\d+)?(?:[eE][+\\-]?\\d+)?)/g, m => {
@@ -991,7 +1419,72 @@ function initSidebarResize() {
991
1419
  function bindEvents() {
992
1420
  el.reloadBtn.addEventListener('click', loadRoutes);
993
1421
  el.search.addEventListener('input', () => { applyFilter(); renderRouteList(); });
994
- el.baseUrl.addEventListener('input', renderUrlBar);
1422
+
1423
+ // ── Global Auth ──
1424
+ const gauthBtn = $('global-auth-btn');
1425
+ const gauthDrop = $('global-auth-dropdown');
1426
+ const gauthBadge = $('gauth-active-badge');
1427
+ const gauthBearer = $('gauth-bearer-token');
1428
+ const gauthKeyName= $('gauth-apikey-name');
1429
+ const gauthKeyVal = $('gauth-apikey-value');
1430
+ const gauthKeyIn = $('gauth-apikey-in');
1431
+
1432
+ gauthBtn.addEventListener('click', e => {
1433
+ e.stopPropagation();
1434
+ gauthDrop.classList.toggle('hidden');
1435
+ gauthBtn.classList.toggle('active', !gauthDrop.classList.contains('hidden'));
1436
+ });
1437
+ document.addEventListener('click', e => {
1438
+ if (!gauthDrop.contains(e.target) && e.target !== gauthBtn) {
1439
+ gauthDrop.classList.add('hidden');
1440
+ gauthBtn.classList.remove('active');
1441
+ }
1442
+ });
1443
+
1444
+ document.querySelectorAll('.gauth-type-btn').forEach(btn => {
1445
+ btn.addEventListener('click', () => {
1446
+ document.querySelectorAll('.gauth-type-btn').forEach(b => b.classList.remove('active'));
1447
+ btn.classList.add('active');
1448
+ const t = btn.dataset.gauth;
1449
+ ['none','bearer','apikey'].forEach(x => $('gauth-'+x+'-panel').classList.toggle('hidden', x !== t));
1450
+ });
1451
+ });
1452
+
1453
+ $('gauth-save-btn').addEventListener('click', () => {
1454
+ const activeBtn = document.querySelector('.gauth-type-btn.active');
1455
+ const type = activeBtn ? activeBtn.dataset.gauth : 'none';
1456
+ S.globalAuth = {
1457
+ type,
1458
+ bearerToken: gauthBearer.value,
1459
+ apiKeyName: gauthKeyName.value || 'X-API-Key',
1460
+ apiKeyValue: gauthKeyVal.value,
1461
+ apiKeyIn: gauthKeyIn.value,
1462
+ };
1463
+ const isActive = type !== 'none' && (S.globalAuth.bearerToken || S.globalAuth.apiKeyValue);
1464
+ gauthBadge.classList.toggle('hidden', !isActive);
1465
+ gauthBtn.classList.toggle('active', isActive);
1466
+ // Show dot indicator
1467
+ const dot = gauthBtn.querySelector('.dot');
1468
+ if (isActive && !dot) {
1469
+ const d = document.createElement('span'); d.className = 'dot';
1470
+ gauthBtn.appendChild(d);
1471
+ } else if (!isActive && dot) dot.remove();
1472
+ gauthDrop.classList.add('hidden');
1473
+ renderUrlBar(); renderSummary();
1474
+ });
1475
+
1476
+ $('gauth-clear-btn').addEventListener('click', () => {
1477
+ S.globalAuth = { type: 'none', bearerToken: '', apiKeyName: 'X-API-Key', apiKeyValue: '', apiKeyIn: 'header' };
1478
+ document.querySelectorAll('.gauth-type-btn').forEach(b => b.classList.remove('active'));
1479
+ document.querySelector('[data-gauth="none"]').classList.add('active');
1480
+ ['none','bearer','apikey'].forEach(x => $('gauth-'+x+'-panel').classList.toggle('hidden', x !== 'none'));
1481
+ gauthBearer.value = ''; gauthKeyVal.value = '';
1482
+ gauthBadge.classList.add('hidden');
1483
+ gauthBtn.classList.remove('active');
1484
+ gauthBtn.querySelector('.dot')?.remove();
1485
+ renderUrlBar();
1486
+ });
1487
+ el.baseUrl.addEventListener('input', () => { renderUrlBar(); renderSummary(); });
995
1488
  el.sendBtn.addEventListener('click', sendRequest);
996
1489
 
997
1490
  el.addQueryBtn.addEventListener('click', () => {
@@ -1029,7 +1522,7 @@ function bindEvents() {
1029
1522
  el.formatBtn.style.display = S.body.type === 'json' ? '' : 'none';
1030
1523
  });
1031
1524
  });
1032
- el.bodyEditor.addEventListener('input', () => { S.body.content = el.bodyEditor.value; });
1525
+ el.bodyEditor.addEventListener('input', () => { S.body.content = el.bodyEditor.value; renderSummary(); });
1033
1526
  el.formatBtn.addEventListener('click', () => {
1034
1527
  try {
1035
1528
  el.bodyEditor.value = JSON.stringify(JSON.parse(el.bodyEditor.value), null, 2);