froggy-docs 1.1.1 → 1.1.3

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.
@@ -0,0 +1,75 @@
1
+ import 'dart:js_interop';
2
+
3
+ import 'package:jaspr/dom.dart';
4
+ import 'package:jaspr/jaspr.dart';
5
+ import 'package:web/web.dart' as web;
6
+
7
+ /// A modular form field component that renders either a text input or a file
8
+ /// input depending on [isFile]. File bytes are surfaced to the parent via
9
+ /// [onFileSelected]; text changes via [onTextChanged].
10
+ class FileField extends StatelessComponent {
11
+ const FileField({
12
+ required this.endpointKey,
13
+ required this.fieldName,
14
+ required this.description,
15
+ required this.isFile,
16
+ required this.fieldType,
17
+ this.savedValue = '',
18
+ required this.onTextChanged,
19
+ required this.onFileSelected,
20
+ super.key,
21
+ });
22
+
23
+ final String endpointKey;
24
+ final String fieldName;
25
+ final String description;
26
+ final bool isFile;
27
+ final String fieldType;
28
+ final String savedValue;
29
+ final void Function(String value) onTextChanged;
30
+ final void Function(List<int> bytes, String name) onFileSelected;
31
+
32
+ @override
33
+ Component build(BuildContext context) {
34
+ return div(
35
+ [
36
+ label([Component.text('$fieldName (${isFile ? 'file' : fieldType})')]),
37
+ if (description.isNotEmpty)
38
+ span([Component.text(description)], classes: 'field-desc'),
39
+ if (isFile)
40
+ input<List<web.File>>(
41
+ type: InputType.file,
42
+ classes: 'form-input file-input',
43
+ onChange: (files) {
44
+ if (files.isNotEmpty) {
45
+ final file = files.first;
46
+ final fileName = file.name;
47
+ final reader = web.FileReader();
48
+ reader.addEventListener(
49
+ 'load',
50
+ ((JSAny? _) {
51
+ final result = reader.result;
52
+ if (result != null) {
53
+ onFileSelected(
54
+ (result as JSArrayBuffer).toDart.asUint8List(),
55
+ fileName,
56
+ );
57
+ }
58
+ }).toJS,
59
+ );
60
+ reader.readAsArrayBuffer(file);
61
+ }
62
+ },
63
+ )
64
+ else
65
+ input<String>(
66
+ type: InputType.text,
67
+ classes: 'form-input',
68
+ value: savedValue,
69
+ onInput: onTextChanged,
70
+ ),
71
+ ],
72
+ classes: 'form-field',
73
+ );
74
+ }
75
+ }
@@ -6,6 +6,8 @@ const responses = {};
6
6
  const loading = {};
7
7
  const requestBodyValues = {};
8
8
  const expandedEndpoints = {};
9
+ const expandedQuery = {};
10
+ const expandedHeaders = {};
9
11
 
10
12
  const methodColors = {
11
13
  GET: '#61affe',
@@ -19,7 +21,11 @@ async function loadData() {
19
21
  try {
20
22
  const resp = await fetch('/froggy_docs.json');
21
23
  if (resp.ok) {
22
- apiData = await resp.json();
24
+ const newData = await resp.json();
25
+ if (apiData && JSON.stringify(apiData) !== JSON.stringify(newData)) {
26
+ showHotReloadToast();
27
+ }
28
+ apiData = newData;
23
29
  render();
24
30
  }
25
31
  } catch (e) {
@@ -27,6 +33,20 @@ async function loadData() {
27
33
  }
28
34
  }
29
35
 
36
+ function showHotReloadToast() {
37
+ let toast = document.getElementById('hot-reload-toast');
38
+ if (!toast) {
39
+ toast = document.createElement('div');
40
+ toast.id = 'hot-reload-toast';
41
+ toast.className = 'toast';
42
+ document.body.appendChild(toast);
43
+ }
44
+ toast.textContent = '🔄 Spec hot-reloaded!';
45
+ toast.style.display = 'block';
46
+ clearTimeout(toast._timer);
47
+ toast._timer = setTimeout(() => { toast.style.display = 'none'; }, 3000);
48
+ }
49
+
30
50
  function getEndpointsByTag() {
31
51
  const byTag = {};
32
52
  const paths = apiData?.paths || {};
@@ -59,10 +79,10 @@ function renderSidebar() {
59
79
  let html = '';
60
80
  for (const [tag, endpoints] of filtered) {
61
81
  const tagMatchesSearch = searchQuery === '' || tag.toLowerCase().includes(searchQuery.toLowerCase());
62
- const tagFiltered = searchQuery === '' || tagMatchesSearch
63
- ? endpoints
82
+ const tagFiltered = tagMatchesSearch
83
+ ? endpoints
64
84
  : endpoints.filter(ep => ep.path.toLowerCase().includes(searchQuery.toLowerCase()));
65
-
85
+
66
86
  if (searchQuery !== '' && tagFiltered.length === 0) continue;
67
87
 
68
88
  html += `
@@ -98,31 +118,134 @@ function renderApiList() {
98
118
  document.getElementById('apiList').innerHTML = html;
99
119
  }
100
120
 
121
+ function getBodyProps(spec) {
122
+ const jsonProps = spec.requestBody?.content?.['application/json']?.schema?.properties;
123
+ const multipartProps = spec.requestBody?.content?.['multipart/form-data']?.schema?.properties;
124
+ return { props: jsonProps || multipartProps, hasFiles: !!multipartProps };
125
+ }
126
+
127
+ function renderFormField(key, name, prop) {
128
+ const isFile = prop.format === 'binary';
129
+ const savedValue = (requestBodyValues[key] || {})[name] || '';
130
+ return `
131
+ <div class="form-field">
132
+ <label>${name} (${isFile ? 'file' : (prop.type || 'string')})</label>
133
+ ${prop.description ? `<span class="field-desc">${prop.description}</span>` : ''}
134
+ ${isFile
135
+ ? `<input type="file" class="form-input file-input" id="file-${key}-${name}">`
136
+ : `<input type="text" class="form-input"
137
+ placeholder="${prop.default || ''}"
138
+ value="${savedValue}"
139
+ oninput="saveBodyValue('${key}', '${name}', this.value)">`
140
+ }
141
+ </div>
142
+ `;
143
+ }
144
+
145
+ function renderQuerySection(key, params) {
146
+ if (!params || params.length === 0) return '';
147
+ const isExpanded = expandedQuery[key] || false;
148
+ return `
149
+ <div class="query-section">
150
+ <button class="collapsible-btn" onclick="toggleQuery('${key}')">
151
+ ${isExpanded ? '▲' : '▼'} Query Params
152
+ </button>
153
+ ${isExpanded ? `
154
+ <div class="params-form">
155
+ ${params.map(qp => {
156
+ const savedVal = (requestBodyValues[`query-${key}`] || {})[qp.name] || '';
157
+ const defaultVal = qp.schema?.default ?? '';
158
+ return `
159
+ <div class="form-field">
160
+ <label>${qp.name} (${qp.schema?.type || 'string'})</label>
161
+ ${qp.description ? `<span class="field-desc">${qp.description}</span>` : ''}
162
+ <input type="text" class="form-input"
163
+ placeholder="${defaultVal}"
164
+ value="${savedVal}"
165
+ oninput="saveQueryValue('${key}', '${qp.name}', this.value)">
166
+ </div>
167
+ `;
168
+ }).join('')}
169
+ </div>
170
+ ` : ''}
171
+ </div>
172
+ `;
173
+ }
174
+
175
+ function renderHeadersSection(key, params) {
176
+ if (!params || params.length === 0) return '';
177
+ const isExpanded = expandedHeaders[key] || false;
178
+ return `
179
+ <div class="headers-section">
180
+ <button class="collapsible-btn" onclick="toggleHeaders('${key}')">
181
+ ${isExpanded ? '▲' : '▼'} Headers
182
+ </button>
183
+ ${isExpanded ? `
184
+ <div class="params-form">
185
+ ${params.map(hp => {
186
+ const savedVal = (requestBodyValues[`header-${key}`] || {})[hp.name] || '';
187
+ return `
188
+ <div class="form-field">
189
+ <label>${hp.name} (${hp.schema?.type || 'string'})</label>
190
+ ${hp.description ? `<span class="field-desc">${hp.description}</span>` : ''}
191
+ <input type="text" class="form-input"
192
+ value="${savedVal}"
193
+ oninput="saveHeaderValue('${key}', '${hp.name}', this.value)">
194
+ </div>
195
+ `;
196
+ }).join('')}
197
+ </div>
198
+ ` : ''}
199
+ </div>
200
+ `;
201
+ }
202
+
203
+ function renderResponseSchemas(responses) {
204
+ if (!responses) return '';
205
+ const entries = Object.entries(responses);
206
+ if (entries.length === 0) return '';
207
+ return `
208
+ <div class="response-schemas">
209
+ <h3 class="section-title">Responses</h3>
210
+ ${entries.map(([code, resp]) => {
211
+ const content = resp.content?.['application/json'];
212
+ const schemaType = content?.schema?.type || 'object';
213
+ const example = content?.example;
214
+ return `
215
+ <div class="response-schema">
216
+ <div class="response-code">${code} - ${resp.description || ''}</div>
217
+ ${content ? `<div class="schema-type">Type: ${schemaType}</div>` : ''}
218
+ ${example != null ? `<pre class="schema-example">${JSON.stringify(example, null, 2)}</pre>` : ''}
219
+ </div>
220
+ `;
221
+ }).join('')}
222
+ </div>
223
+ `;
224
+ }
225
+
101
226
  function renderEndpoint(path, method, spec) {
102
227
  const key = `${method}-${path}`;
103
- const props = spec.requestBody?.content?.['application/json']?.schema?.properties;
228
+ const { props, hasFiles } = getBodyProps(spec);
104
229
  const hasAuth = spec.security != null;
105
230
  const isExpanded = expandedEndpoints[key] || false;
106
- const savedValues = requestBodyValues[key] || {};
107
-
108
- let paramsHtml = '';
109
- if (props && Object.keys(props).length > 0) {
110
- paramsHtml = `
111
- <h3 class="section-title">Parameters</h3>
112
- <table class="params-table">
113
- <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
114
- <tbody>
115
- ${Object.entries(props).map(([name, prop]) => `
116
- <tr>
117
- <td><b>${name}</b></td>
118
- <td><span class="type-label">${prop.type || 'string'}</span></td>
119
- <td>${prop.description || '-'}</td>
120
- </tr>
121
- `).join('')}
122
- </tbody>
123
- </table>
124
- `;
125
- }
231
+
232
+ const queryParams = (spec.parameters || []).filter(p => p.in === 'query');
233
+ const headerParams = (spec.parameters || []).filter(p => p.in === 'header');
234
+
235
+ const formHtml = props && Object.keys(props).length > 0 ? `
236
+ <button class="try-it-out-btn ${isExpanded ? '' : 'secondary'}" onclick="toggleBody('${key}')">
237
+ ${isExpanded ? 'Hide Request Body' : 'Show Request Body'}
238
+ </button>
239
+ ${isExpanded ? `
240
+ <h3 class="section-title">${hasFiles ? 'Multipart Form Data' : 'Request Body'}</h3>
241
+ <div class="request-body-form">
242
+ ${Object.entries(props).map(([name, prop]) => renderFormField(key, name, prop)).join('')}
243
+ </div>
244
+ ` : ''}
245
+ ` : '';
246
+
247
+ const propsJson = props ? JSON.stringify(props).replace(/"/g, '&quot;') : 'null';
248
+ const hasFilesStr = hasFiles ? 'true' : 'false';
126
249
 
127
250
  return `
128
251
  <section class="api-section" id="${method.toUpperCase()}-${path}">
@@ -132,83 +255,117 @@ function renderEndpoint(path, method, spec) {
132
255
  ${hasAuth ? '<span class="auth-badge">🔒 Auth</span>' : ''}
133
256
  </div>
134
257
  <p class="api-description">${spec.summary || 'No description'}</p>
135
-
136
- ${props && Object.keys(props).length > 0 ? `
137
- <button class="try-it-out-btn ${isExpanded ? '' : 'secondary'}" onclick="toggleBody('${key}')">
138
- ${isExpanded ? 'Hide Request Body' : 'Show Request Body'}
139
- </button>
140
- ${isExpanded ? `
141
- <h3 class="section-title">Request Body</h3>
142
- <div class="request-body-form">
143
- ${Object.entries(props).map(([name, prop]) => `
144
- <div class="form-field">
145
- <label>${name} (${prop.type || 'string'})</label>
146
- ${prop.description ? `<span class="field-desc">${prop.description}</span>` : ''}
147
- <input type="text" class="form-input" placeholder="${prop.default || ''}"
148
- value="${savedValues[name] || ''}"
149
- oninput="saveBodyValue('${key}', '${name}', this.value)">
150
- </div>
151
- `).join('')}
152
- </div>
153
- ` : ''}
154
- ` : ''}
155
258
 
156
- <button class="try-it-out-btn" onclick="executeRequest('${method}', '${path}', ${hasAuth}, ${props ? JSON.stringify(props).replace(/"/g, '&quot;') : 'null'})">
259
+ ${formHtml}
260
+ ${renderQuerySection(key, queryParams)}
261
+ ${renderHeadersSection(key, headerParams)}
262
+ ${renderResponseSchemas(spec.responses)}
263
+
264
+ <button class="try-it-out-btn" onclick="executeRequest('${method}', '${path}', ${hasAuth}, ${propsJson}, ${hasFilesStr})">
157
265
  ${loading[key] ? 'Loading...' : 'Try It Out'}
158
266
  </button>
159
267
 
160
268
  ${responses[key] ? `
161
269
  <div class="response-section">
162
270
  <h4>Response:</h4>
163
- <pre class="response-body">${responses[key]}</pre>
271
+ <pre class="response-body">${escapeHtml(responses[key])}</pre>
164
272
  </div>
165
273
  ` : ''}
166
-
167
- ${paramsHtml}
168
274
  </section>
169
275
  `;
170
276
  }
171
277
 
278
+ function escapeHtml(str) {
279
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
280
+ }
281
+
172
282
  function toggleBody(key) {
173
283
  expandedEndpoints[key] = !expandedEndpoints[key];
174
284
  renderApiList();
175
285
  }
176
286
 
287
+ function toggleQuery(key) {
288
+ expandedQuery[key] = !expandedQuery[key];
289
+ renderApiList();
290
+ }
291
+
292
+ function toggleHeaders(key) {
293
+ expandedHeaders[key] = !expandedHeaders[key];
294
+ renderApiList();
295
+ }
296
+
177
297
  function saveBodyValue(key, field, value) {
178
298
  if (!requestBodyValues[key]) requestBodyValues[key] = {};
179
299
  requestBodyValues[key][field] = value;
180
300
  }
181
301
 
182
- async function executeRequest(method, path, hasAuth, props) {
302
+ function saveQueryValue(key, field, value) {
303
+ const qKey = `query-${key}`;
304
+ if (!requestBodyValues[qKey]) requestBodyValues[qKey] = {};
305
+ requestBodyValues[qKey][field] = value;
306
+ }
307
+
308
+ function saveHeaderValue(key, field, value) {
309
+ const hKey = `header-${key}`;
310
+ if (!requestBodyValues[hKey]) requestBodyValues[hKey] = {};
311
+ requestBodyValues[hKey][field] = value;
312
+ }
313
+
314
+ async function executeRequest(method, path, hasAuth, props, hasFiles) {
183
315
  const key = `${method}-${path}`;
184
316
  loading[key] = true;
185
317
  renderApiList();
186
318
 
187
319
  try {
188
- const headers = { 'Content-Type': 'application/json' };
320
+ const headers = {};
189
321
  if (authHeader) headers['Authorization'] = authHeader;
190
322
 
191
- let body = '{}';
192
- if (props && Object.keys(props).length > 0) {
193
- const bodyData = {};
194
- const savedValues = requestBodyValues[key] || {};
195
- for (const [name, prop] of Object.entries(props)) {
196
- const value = savedValues[name] || prop.default || '';
197
- if (value) bodyData[name] = prop.type === 'number' ? Number(value) : value;
198
- }
199
- body = JSON.stringify(bodyData);
323
+ // Add saved custom headers
324
+ const savedHeaders = requestBodyValues[`header-${key}`] || {};
325
+ for (const [name, val] of Object.entries(savedHeaders)) {
326
+ if (val) headers[name] = val;
327
+ }
328
+
329
+ // Build URL with query params
330
+ let url = `${window.location.origin}${path}`;
331
+ const savedQuery = requestBodyValues[`query-${key}`] || {};
332
+ const queryParts = Object.entries(savedQuery).filter(([, v]) => v);
333
+ if (queryParts.length > 0) {
334
+ url += '?' + queryParts.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
200
335
  }
201
336
 
202
337
  let resp;
203
- const url = `${window.location.origin}${path}`;
204
- const options = { method, headers };
205
338
 
206
- if (method !== 'GET' && method !== 'DELETE') {
207
- options.body = body;
339
+ if (hasFiles && props) {
340
+ // Multipart: use FormData to carry both text fields and file inputs
341
+ const formData = new FormData();
342
+ for (const [name, prop] of Object.entries(props)) {
343
+ if (prop.format === 'binary') {
344
+ const fileInput = document.getElementById(`file-${key}-${name}`);
345
+ if (fileInput && fileInput.files && fileInput.files[0]) {
346
+ formData.append(name, fileInput.files[0]);
347
+ }
348
+ } else {
349
+ const val = (requestBodyValues[key] || {})[name] || prop.default || '';
350
+ if (val) formData.append(name, val);
351
+ }
352
+ }
353
+ resp = await fetch(url, { method, headers, body: formData });
354
+ } else {
355
+ headers['Content-Type'] = 'application/json';
356
+ let body;
357
+ if (props && method !== 'GET' && method !== 'HEAD') {
358
+ const bodyData = {};
359
+ const savedValues = requestBodyValues[key] || {};
360
+ for (const [name, prop] of Object.entries(props)) {
361
+ const value = savedValues[name] || prop.default || '';
362
+ if (value) bodyData[name] = prop.type === 'number' ? Number(value) : value;
363
+ }
364
+ body = JSON.stringify(bodyData);
365
+ }
366
+ resp = await fetch(url, { method, headers, body });
208
367
  }
209
368
 
210
- resp = await fetch(url, options);
211
-
212
369
  let responseText;
213
370
  try {
214
371
  const json = await resp.json();
@@ -248,4 +405,4 @@ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
248
405
  }
249
406
 
250
407
  loadData();
251
- setInterval(loadData, 3000);
408
+ setInterval(loadData, 3000);