zero-http 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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/documentation/controllers/cleanup.js +25 -0
  4. package/documentation/controllers/echo.js +14 -0
  5. package/documentation/controllers/headers.js +1 -0
  6. package/documentation/controllers/proxy.js +112 -0
  7. package/documentation/controllers/root.js +1 -0
  8. package/documentation/controllers/uploads.js +289 -0
  9. package/documentation/full-server.js +129 -0
  10. package/documentation/public/data/api.json +167 -0
  11. package/documentation/public/data/examples.json +62 -0
  12. package/documentation/public/data/options.json +13 -0
  13. package/documentation/public/index.html +414 -0
  14. package/documentation/public/prism-overrides.css +40 -0
  15. package/documentation/public/scripts/app.js +44 -0
  16. package/documentation/public/scripts/data-sections.js +300 -0
  17. package/documentation/public/scripts/helpers.js +166 -0
  18. package/documentation/public/scripts/playground.js +71 -0
  19. package/documentation/public/scripts/proxy.js +98 -0
  20. package/documentation/public/scripts/ui.js +210 -0
  21. package/documentation/public/scripts/uploads.js +459 -0
  22. package/documentation/public/styles.css +310 -0
  23. package/documentation/public/vendor/icons/fetch.svg +23 -0
  24. package/documentation/public/vendor/icons/plug.svg +27 -0
  25. package/documentation/public/vendor/icons/static.svg +35 -0
  26. package/documentation/public/vendor/icons/stream.svg +22 -0
  27. package/documentation/public/vendor/icons/zero.svg +21 -0
  28. package/documentation/public/vendor/prism-copy-to-clipboard.min.js +27 -0
  29. package/documentation/public/vendor/prism-javascript.min.js +1 -0
  30. package/documentation/public/vendor/prism-json.min.js +1 -0
  31. package/documentation/public/vendor/prism-okaidia.css +1 -0
  32. package/documentation/public/vendor/prism-toolbar.css +27 -0
  33. package/documentation/public/vendor/prism-toolbar.min.js +41 -0
  34. package/documentation/public/vendor/prism.min.js +1 -0
  35. package/index.js +43 -0
  36. package/lib/app.js +159 -0
  37. package/lib/body/index.js +14 -0
  38. package/lib/body/json.js +54 -0
  39. package/lib/body/multipart.js +310 -0
  40. package/lib/body/raw.js +40 -0
  41. package/lib/body/rawBuffer.js +74 -0
  42. package/lib/body/sendError.js +17 -0
  43. package/lib/body/text.js +43 -0
  44. package/lib/body/typeMatch.js +22 -0
  45. package/lib/body/urlencoded.js +166 -0
  46. package/lib/cors.js +72 -0
  47. package/lib/fetch.js +218 -0
  48. package/lib/logger.js +68 -0
  49. package/lib/rateLimit.js +64 -0
  50. package/lib/request.js +76 -0
  51. package/lib/response.js +165 -0
  52. package/lib/router.js +87 -0
  53. package/lib/static.js +196 -0
  54. package/package.json +44 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * ui.js
3
+ * Shell UI behaviours — feature tabs, TOC sidebar toggle, and smooth-scroll
4
+ * anchor navigation. Runs on DOMContentLoaded alongside the other scripts.
5
+ *
6
+ * No external dependencies — pure DOM.
7
+ */
8
+
9
+ document.addEventListener('DOMContentLoaded', () =>
10
+ {
11
+ initFeatureTabs();
12
+ initTocSidebar();
13
+ initTocNavigation();
14
+ initTocToolbar();
15
+ });
16
+
17
+ /* ── Feature Tabs ──────────────────────────────────────────────────────────── */
18
+
19
+ /**
20
+ * Wire the feature / server-model tab buttons so clicking one activates
21
+ * its panel and deactivates the rest.
22
+ */
23
+ function initFeatureTabs()
24
+ {
25
+ const tabs = document.querySelectorAll('.feature-tabs .tab');
26
+ tabs.forEach(tab =>
27
+ {
28
+ tab.addEventListener('click', () =>
29
+ {
30
+ const target = tab.dataset.target;
31
+ if (!target) return;
32
+
33
+ tabs.forEach(t =>
34
+ {
35
+ t.classList.remove('active');
36
+ t.setAttribute('aria-selected', 'false');
37
+ });
38
+
39
+ document.querySelectorAll('.feature-tabs .tab-panel').forEach(p => p.classList.remove('active'));
40
+
41
+ tab.classList.add('active');
42
+ tab.setAttribute('aria-selected', 'true');
43
+
44
+ const panel = document.getElementById(target);
45
+ if (panel) panel.classList.add('active');
46
+ });
47
+ });
48
+ }
49
+
50
+ /* ── TOC Sidebar Toggle ────────────────────────────────────────────────────── */
51
+
52
+ /**
53
+ * Wire the hamburger button to toggle the sidebar on both desktop (persistent)
54
+ * and mobile (overlay). Escape key and outside clicks close the mobile overlay.
55
+ */
56
+ function initTocSidebar()
57
+ {
58
+ const btn = document.querySelector('.toc-toggle');
59
+ const sidebar = document.querySelector('.toc-sidebar');
60
+ if (!btn || !sidebar) return;
61
+
62
+ const isDesktop = () => window.matchMedia('(min-width:900px)').matches;
63
+
64
+ const syncAria = () =>
65
+ {
66
+ const expanded = isDesktop()
67
+ ? !document.body.classList.contains('toc-hidden')
68
+ : document.body.classList.contains('toc-open');
69
+ btn.setAttribute('aria-expanded', String(expanded));
70
+ };
71
+
72
+ syncAria();
73
+
74
+ btn.addEventListener('click', () =>
75
+ {
76
+ if (isDesktop())
77
+ {
78
+ document.body.classList.toggle('toc-hidden');
79
+ document.body.classList.remove('toc-open');
80
+ }
81
+ else
82
+ {
83
+ document.body.classList.toggle('toc-open');
84
+ }
85
+ syncAria();
86
+ });
87
+
88
+ document.addEventListener('keydown', (e) =>
89
+ {
90
+ if (e.key === 'Escape')
91
+ {
92
+ document.body.classList.remove('toc-open');
93
+ document.body.classList.remove('toc-hidden');
94
+ syncAria();
95
+ }
96
+ });
97
+
98
+ document.addEventListener('click', (e) =>
99
+ {
100
+ if (!document.body.classList.contains('toc-open')) return;
101
+ if (e.target.closest('.toc-sidebar') || e.target.closest('.toc-toggle')) return;
102
+ document.body.classList.remove('toc-open');
103
+ syncAria();
104
+ });
105
+
106
+ window.addEventListener('resize', syncAria);
107
+ }
108
+
109
+ /* ── TOC Smooth-Scroll Navigation ──────────────────────────────────────────── */
110
+
111
+ /**
112
+ * When clicking a TOC link that points to a `#hash`, auto-open any ancestor
113
+ * `<details>` accordions so the target is visible, then smooth-scroll to it.
114
+ * Also handles the browser `hashchange` event for direct URL navigation.
115
+ */
116
+ function initTocNavigation()
117
+ {
118
+ const nav = document.querySelector('.toc-sidebar nav');
119
+ if (!nav) return;
120
+
121
+ /**
122
+ * Recursively open every `<details class="acc">` ancestor of the given
123
+ * element so it becomes visible.
124
+ * @param {Element} el - Starting element.
125
+ */
126
+ function openAncestors(el)
127
+ {
128
+ let d = el.closest('details.acc');
129
+ while (d)
130
+ {
131
+ d.open = true;
132
+ d = d.parentElement ? d.parentElement.closest('details.acc') : null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Scroll to an element by id, opening any accordion parents first.
138
+ * @param {string} id - Target element id.
139
+ */
140
+ function scrollToId(id)
141
+ {
142
+ const target = document.getElementById(id);
143
+ if (!target) return;
144
+ openAncestors(target);
145
+ setTimeout(() => target.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
146
+ }
147
+
148
+ nav.addEventListener('click', (e) =>
149
+ {
150
+ const a = e.target.closest('a[href^="#"]');
151
+ if (!a) return;
152
+ const hash = a.getAttribute('href');
153
+ if (!hash || hash.charAt(0) !== '#') return;
154
+
155
+ scrollToId(hash.slice(1));
156
+ document.body.classList.remove('toc-open');
157
+
158
+ const btn = document.querySelector('.toc-toggle');
159
+ if (btn) btn.setAttribute('aria-expanded', 'false');
160
+ });
161
+
162
+ window.addEventListener('hashchange', () =>
163
+ {
164
+ const id = location.hash ? location.hash.slice(1) : '';
165
+ if (id) scrollToId(id);
166
+ });
167
+ }
168
+
169
+ /* ── TOC Toolbar (scroll-to-top & expand/collapse all) ─────────────────────── */
170
+
171
+ /**
172
+ * Wire the icon-bar buttons at the top of the sidebar:
173
+ * - Scroll to top
174
+ * - Expand / collapse every `<details class="acc">` on the page
175
+ */
176
+ function initTocToolbar()
177
+ {
178
+ const topBtn = document.getElementById('toc-top-btn');
179
+ const toggleBtn = document.getElementById('toc-toggle-acc');
180
+ if (!topBtn && !toggleBtn) return;
181
+
182
+ /* ── Scroll to top ────────────────────────────────────────────────── */
183
+ const brandBtn = document.getElementById('brand-top');
184
+
185
+ [topBtn, brandBtn].forEach(el =>
186
+ {
187
+ if (!el) return;
188
+ el.addEventListener('click', (e) =>
189
+ {
190
+ e.preventDefault();
191
+ window.scrollTo({ top: 0, behavior: 'smooth' });
192
+ });
193
+ });
194
+
195
+ /* ── Expand / Collapse all accordions ─────────────────────────────── */
196
+ if (toggleBtn)
197
+ {
198
+ let expanded = false;
199
+
200
+ toggleBtn.addEventListener('click', () =>
201
+ {
202
+ expanded = !expanded;
203
+ document.querySelectorAll('details.acc').forEach(d => d.open = expanded);
204
+
205
+ toggleBtn.classList.toggle('acc-expanded', expanded);
206
+ toggleBtn.title = expanded ? 'Collapse all' : 'Expand all';
207
+ toggleBtn.setAttribute('aria-label', expanded ? 'Collapse all sections' : 'Expand all sections');
208
+ });
209
+ }
210
+ }
@@ -0,0 +1,459 @@
1
+ /**
2
+ * uploads.js
3
+ * Upload form handling, file list with pagination, trash management, undo,
4
+ * and the combined uploads+trash JSON view.
5
+ *
6
+ * Depends on: helpers.js (provides $, on, escapeHtml, formatBytes,
7
+ * showJsonResult, highlightAllPre)
8
+ */
9
+
10
+ /* ── Pagination State ──────────────────────────────────────────────────────── */
11
+
12
+ let currentPage = 1;
13
+ let currentSort = 'mtime';
14
+ let currentOrder = 'desc';
15
+ const pageSize = 4;
16
+
17
+ /* ── Combined Uploads + Trash JSON ─────────────────────────────────────────── */
18
+
19
+ /**
20
+ * Fetch the combined uploads + trash listing and render it as JSON into the
21
+ * `#uploadResult` container.
22
+ */
23
+ async function loadUploadsCombined()
24
+ {
25
+ try
26
+ {
27
+ const r = await fetch('/uploads-all', { cache: 'no-store' });
28
+ const j = await r.json();
29
+ showJsonResult($('#uploadResult'), j);
30
+ } catch (e) { }
31
+ }
32
+
33
+ /* ── Convenience: DELETE + Show Result ─────────────────────────────────────── */
34
+
35
+ /**
36
+ * Issue a DELETE request and display the JSON response.
37
+ * @param {string} path - Request path.
38
+ * @param {Element|null} container - Output element (defaults to `#uploadResult`).
39
+ */
40
+ async function deleteAndShow(path, container)
41
+ {
42
+ container = container || $('#uploadResult');
43
+ const r = await fetch(path, { method: 'DELETE' });
44
+ let j;
45
+ try { j = await r.json(); } catch (e) { container.textContent = 'Error parsing response'; return; }
46
+ try { await loadUploadsCombined(); } catch (e) { showJsonResult(container, j); }
47
+ }
48
+
49
+ /* ── Trash Row Factory ─────────────────────────────────────────────────────── */
50
+
51
+ /**
52
+ * Build a trash-row DOM node with Restore and Delete Permanently buttons.
53
+ * @param {string} name - Filename in trash.
54
+ * @returns {HTMLElement}
55
+ */
56
+ function createTrashRow(name)
57
+ {
58
+ const row = document.createElement('div');
59
+ row.className = 'fileRow trash';
60
+
61
+ const nameDiv = document.createElement('div');
62
+ nameDiv.innerHTML = `<div>${escapeHtml(name)}</div>`;
63
+
64
+ const restore = document.createElement('button');
65
+ restore.textContent = 'Restore';
66
+ restore.className = 'btn';
67
+ restore.addEventListener('click', async () =>
68
+ {
69
+ await fetch('/uploads/' + encodeURIComponent(name) + '/restore', { method: 'POST' });
70
+ try { row.remove(); } catch (e) { }
71
+ try { await loadUploadsCombined(); } catch (e) { }
72
+ loadUploadsList();
73
+ loadTrashList();
74
+ });
75
+
76
+ const del = document.createElement('button');
77
+ del.textContent = 'Delete Permanently';
78
+ del.className = 'btn warn';
79
+ del.addEventListener('click', async () =>
80
+ {
81
+ if (!confirm('Permanently delete ' + name + '?')) return;
82
+ await fetch('/uploads-trash/' + encodeURIComponent(name), { method: 'DELETE' });
83
+ try { row.remove(); } catch (e) { }
84
+ loadTrashList();
85
+ });
86
+
87
+ row.appendChild(nameDiv);
88
+ row.appendChild(restore);
89
+ row.appendChild(del);
90
+ return row;
91
+ }
92
+
93
+ /* ── Trash List ────────────────────────────────────────────────────────────── */
94
+
95
+ /**
96
+ * Fetch the trash listing from the server and render rows into `#trashList`.
97
+ */
98
+ async function loadTrashList()
99
+ {
100
+ try
101
+ {
102
+ const r = await fetch('/uploads-trash-list', { cache: 'no-store' });
103
+ const j = await r.json();
104
+ const trashList = $('#trashList');
105
+ if (trashList) trashList.innerHTML = '';
106
+ try { await loadUploadsCombined(); } catch (e) { showJsonResult($('#uploadResult'), j); }
107
+ for (const f of j.files) trashList.appendChild(createTrashRow(f.name));
108
+ } catch (e)
109
+ {
110
+ const trashList = $('#trashList');
111
+ if (trashList) trashList.textContent = 'Error loading trash';
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Optimistically insert a single row into the trash list without a full reload.
117
+ * @param {string} name - Filename that was just trashed.
118
+ */
119
+ function addTrashRow(name)
120
+ {
121
+ const trashList = $('#trashList');
122
+ if (!trashList) return;
123
+ const row = createTrashRow(name);
124
+ if (trashList.firstChild) trashList.insertBefore(row, trashList.firstChild);
125
+ else trashList.appendChild(row);
126
+ }
127
+
128
+ /* ── Undo Toast ────────────────────────────────────────────────────────────── */
129
+
130
+ /**
131
+ * Display a transient undo panel when a file is moved to trash.
132
+ * @param {string} name - Trashed filename.
133
+ */
134
+ function showUndo(name)
135
+ {
136
+ const box = document.createElement('div');
137
+ box.className = 'panel';
138
+ box.textContent = `Trashed ${name} — `;
139
+
140
+ const btn = document.createElement('button');
141
+ btn.textContent = 'Undo';
142
+ btn.className = 'btn';
143
+ box.appendChild(btn);
144
+
145
+ try
146
+ {
147
+ const shell = document.querySelector('.ui-shell') || document.body;
148
+ shell.prepend(box);
149
+ } catch (e) { }
150
+
151
+ const tid = setTimeout(() => box.remove(), 8000);
152
+
153
+ btn.addEventListener('click', async () =>
154
+ {
155
+ clearTimeout(tid);
156
+ await fetch('/uploads/' + encodeURIComponent(name) + '/restore', { method: 'POST' });
157
+ box.remove();
158
+ try { await loadUploadsCombined(); } catch (e) { }
159
+ loadUploadsList();
160
+ loadTrashList();
161
+ });
162
+ }
163
+
164
+ /* ── Upload Card Factory ───────────────────────────────────────────────────── */
165
+
166
+ /** Inline SVG placeholder for non-image files. */
167
+ const PLACEHOLDER_SVG = 'data:image/svg+xml;utf8,' + encodeURIComponent(
168
+ '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">' +
169
+ '<rect width="100%" height="100%" fill="#eef2ff" rx="8" ry="8"/>' +
170
+ '<text x="50%" y="50%" font-family="Arial,Helvetica,sans-serif" font-size="12" ' +
171
+ 'fill="#111827" dominant-baseline="middle" text-anchor="middle">file</text></svg>'
172
+ );
173
+
174
+ /**
175
+ * Build a file-card DOM node for a single uploaded file.
176
+ * @param {Object} f - File descriptor from the server.
177
+ * @param {Element} uploadResult - Container for JSON feedback.
178
+ * @returns {HTMLElement}
179
+ */
180
+ function createUploadCard(f, uploadResult)
181
+ {
182
+ const card = document.createElement('div');
183
+ card.className = 'file-card';
184
+
185
+ /* Thumbnail / image preview */
186
+ const img = document.createElement('img');
187
+ img.src = f.thumb || (f.isImage ? f.url : PLACEHOLDER_SVG);
188
+ img.alt = f.name || '';
189
+ img.loading = 'lazy';
190
+ img.className = 'thumb';
191
+ card.appendChild(img);
192
+
193
+ /* File metadata */
194
+ const info = document.createElement('div');
195
+ info.className = 'file-meta';
196
+
197
+ const title = document.createElement('div');
198
+ title.className = 'file-title';
199
+ title.textContent = f.name;
200
+
201
+ const meta = document.createElement('div');
202
+ meta.className = 'file-submeta';
203
+ meta.textContent = `${formatBytes(f.size)} • ${new Date(f.mtime).toLocaleString()}`;
204
+
205
+ info.appendChild(title);
206
+ info.appendChild(meta);
207
+ card.appendChild(info);
208
+
209
+ /* Action buttons */
210
+ const actions = document.createElement('div');
211
+ actions.className = 'file-actions';
212
+
213
+ const dl = document.createElement('a');
214
+ dl.href = f.url;
215
+ dl.target = '_blank';
216
+ dl.className = 'btn small';
217
+ dl.textContent = 'Download';
218
+
219
+ const del = document.createElement('button');
220
+ del.textContent = 'Trash';
221
+ del.className = 'btn warn';
222
+ del.addEventListener('click', async () =>
223
+ {
224
+ if (!confirm('Move ' + f.name + ' to trash?')) return;
225
+ const resp = await fetch('/uploads/' + encodeURIComponent(f.name), { method: 'DELETE' });
226
+ try { await loadUploadsCombined(); } catch (e)
227
+ {
228
+ try { showJsonResult($('#uploadResult'), await resp.json()); }
229
+ catch (err) { $('#uploadResult').textContent = 'Error'; }
230
+ }
231
+ showUndo(f.name);
232
+ try { card.remove(); } catch (e) { }
233
+ addTrashRow(f.name);
234
+ loadUploadsList();
235
+ loadTrashList().catch(() => {});
236
+ });
237
+
238
+ actions.appendChild(dl);
239
+ actions.appendChild(del);
240
+ card.appendChild(actions);
241
+ return card;
242
+ }
243
+
244
+ /* ── Paginated Upload List ─────────────────────────────────────────────────── */
245
+
246
+ /**
247
+ * Fetch the paginated upload list and render file cards into `#uploadsList`.
248
+ */
249
+ async function loadUploadsList()
250
+ {
251
+ const uploadsList = $('#uploadsList');
252
+ try
253
+ {
254
+ const url = `/uploads-list?page=${currentPage}&pageSize=${pageSize}` +
255
+ `&sort=${encodeURIComponent(currentSort)}&order=${encodeURIComponent(currentOrder)}`;
256
+ const r = await fetch(url, { cache: 'no-store' });
257
+ const j = await r.json();
258
+
259
+ const pageInfo = $('#pageInfo');
260
+ const prevPageBtn = $('#prevPage');
261
+ const nextPageBtn = $('#nextPage');
262
+ const uploadResult = $('#uploadResult');
263
+
264
+ if (uploadsList) uploadsList.innerHTML = '';
265
+
266
+ const total = Number(j.total || 0);
267
+ const maxPages = Math.max(1, Math.ceil(total / (j.pageSize || pageSize)));
268
+
269
+ /* Clamp to last page if we overshot */
270
+ if ((j.page || currentPage) > maxPages)
271
+ {
272
+ currentPage = maxPages;
273
+ return loadUploadsList();
274
+ }
275
+
276
+ /* Empty state */
277
+ if (!j.files || j.files.length === 0)
278
+ {
279
+ if (total === 0)
280
+ {
281
+ if (uploadsList) uploadsList.textContent = 'No uploads yet';
282
+ if (pageInfo) pageInfo.textContent = '0 / 0';
283
+ }
284
+ else
285
+ {
286
+ if (uploadsList) uploadsList.textContent = 'No uploads on this page';
287
+ if (pageInfo) pageInfo.textContent = `${j.page || currentPage} / ${maxPages}`;
288
+ }
289
+ if (prevPageBtn) prevPageBtn.disabled = (currentPage <= 1);
290
+ if (nextPageBtn) nextPageBtn.disabled = (currentPage >= maxPages);
291
+ return;
292
+ }
293
+
294
+ /* Pagination controls */
295
+ if (pageInfo) pageInfo.textContent = `${j.page} / ${maxPages}`;
296
+ if (prevPageBtn) prevPageBtn.disabled = (j.page <= 1);
297
+ if (nextPageBtn) nextPageBtn.disabled = (j.page >= maxPages);
298
+
299
+ /* Render cards */
300
+ for (const f of j.files)
301
+ {
302
+ if (f.name === '.thumbs') continue;
303
+ const card = createUploadCard(f, uploadResult);
304
+ if (uploadsList) uploadsList.appendChild(card);
305
+ }
306
+ try { highlightAllPre(); } catch (e) { }
307
+ } catch (e)
308
+ {
309
+ if (uploadsList) uploadsList.textContent = 'Error loading list';
310
+ }
311
+ }
312
+
313
+ /* ── Wire Upload Form & Bulk Actions ───────────────────────────────────────── */
314
+
315
+ /**
316
+ * Initialise the upload form (XHR with progress), pagination controls, and
317
+ * bulk delete / empty-trash buttons. Called once from the DOMContentLoaded
318
+ * handler in app.js.
319
+ */
320
+ function initUploads()
321
+ {
322
+ /* Upload form */
323
+ on($('#uploadForm'), 'submit', (e) =>
324
+ {
325
+ e.preventDefault();
326
+ try
327
+ {
328
+ const uploadForm = $('#uploadForm');
329
+ const fileInput = $('#fileInput');
330
+ const uploadProgress = $('#uploadProgress');
331
+ const uploadResult = $('#uploadResult');
332
+
333
+ const files = (fileInput && fileInput.files) ? fileInput.files : [];
334
+ if (!files || files.length === 0) { uploadResult.textContent = 'No file selected'; return; }
335
+
336
+ const fd = new FormData();
337
+ for (const f of files) fd.append('file', f, f.name);
338
+
339
+ const xhr = new XMLHttpRequest();
340
+ xhr.open('POST', '/upload');
341
+
342
+ try { uploadProgress.style.display = 'block'; uploadProgress.value = 0; } catch (e) { }
343
+
344
+ const controls = Array.from(uploadForm.querySelectorAll('button, input, select'));
345
+ controls.forEach(c => c.disabled = true);
346
+
347
+ if (xhr.upload)
348
+ {
349
+ xhr.upload.onprogress = (ev) =>
350
+ {
351
+ if (ev.lengthComputable)
352
+ {
353
+ try { uploadProgress.value = Math.round((ev.loaded / ev.total) * 100); } catch (e) { }
354
+ }
355
+ };
356
+ }
357
+
358
+ xhr.onload = () =>
359
+ {
360
+ try { uploadProgress.style.display = 'none'; } catch (e) { }
361
+ try { showJsonResult(uploadResult, JSON.parse(xhr.responseText)); }
362
+ catch (err) { uploadResult.textContent = xhr.responseText; }
363
+ controls.forEach(c => c.disabled = false);
364
+ loadUploadsList();
365
+ };
366
+
367
+ xhr.onerror = () =>
368
+ {
369
+ try { uploadProgress.style.display = 'none'; } catch (e) { }
370
+ uploadResult.textContent = 'Upload failed';
371
+ controls.forEach(c => c.disabled = false);
372
+ };
373
+
374
+ xhr.send(fd);
375
+ } catch (e)
376
+ {
377
+ try { const p = $('#uploadProgress'); if (p) p.style.display = 'none'; } catch (err) { }
378
+ const r = $('#uploadResult'); if (r) r.textContent = 'Upload error';
379
+ }
380
+ });
381
+
382
+ /* Bulk delete buttons */
383
+ on($('#delAllBtn'), 'click', async () =>
384
+ {
385
+ if (!confirm('Delete all uploads?')) return;
386
+ const r = await fetch('/uploads', { method: 'DELETE' });
387
+ const j = await r.json();
388
+ try { await loadUploadsCombined(); } catch (e) { showJsonResult($('#uploadResult'), j); }
389
+ loadUploadsList();
390
+ });
391
+
392
+ on($('#delKeepBtn'), 'click', async () =>
393
+ {
394
+ if (!confirm('Delete all uploads but keep the first?')) return;
395
+ const r = await fetch('/uploads?keep=1', { method: 'DELETE' });
396
+ const j = await r.json();
397
+ try { await loadUploadsCombined(); } catch (e) { showJsonResult($('#uploadResult'), j); }
398
+ loadUploadsList();
399
+ });
400
+
401
+ /* Pagination / sorting controls */
402
+ on($('#sortSelect'), 'change', () => { currentSort = $('#sortSelect').value; currentPage = 1; loadUploadsList(); });
403
+ on($('#sortOrder'), 'change', () => { currentOrder = $('#sortOrder').value; currentPage = 1; loadUploadsList(); });
404
+ on($('#prevPage'), 'click', () => { if (currentPage > 1) { currentPage--; loadUploadsList(); } });
405
+ on($('#nextPage'), 'click', () => { currentPage++; loadUploadsList(); });
406
+
407
+ /* Empty trash */
408
+ on($('#emptyTrashBtn'), 'click', async () =>
409
+ {
410
+ if (!confirm('Empty trash? This will permanently delete items.')) return;
411
+ const r = await fetch('/uploads-trash', { method: 'DELETE' });
412
+ const j = await r.json();
413
+ try { await loadUploadsCombined(); } catch (e) { showJsonResult($('#uploadResult'), j); }
414
+ loadTrashList();
415
+ });
416
+
417
+ /* File drop / choose area */
418
+ const fileDrop = $('#fileDrop');
419
+ const fileInput = $('#fileInput');
420
+ const uploadResult = $('#uploadResult');
421
+ const fileDropInner = fileDrop && fileDrop.querySelector('.fileDrop-inner');
422
+
423
+ if (fileDrop && fileInput)
424
+ {
425
+ fileDrop.addEventListener('click', (ev) =>
426
+ {
427
+ if (ev.target.tagName === 'INPUT' || ev.target.closest('label')) return;
428
+ fileInput.click();
429
+ });
430
+
431
+ fileInput.addEventListener('change', () =>
432
+ {
433
+ const names = fileInput.files && fileInput.files.length
434
+ ? Array.from(fileInput.files).map(f => f.name).join(', ')
435
+ : '';
436
+ if (names)
437
+ {
438
+ if (fileDropInner) fileDropInner.textContent = names;
439
+ if (uploadResult) uploadResult.textContent = 'Selected: ' + names;
440
+ }
441
+ else
442
+ {
443
+ if (fileDropInner) fileDropInner.innerHTML = 'Drop files here or <label for="fileInput" class="linkish">choose file</label>';
444
+ if (uploadResult) uploadResult.textContent = '';
445
+ }
446
+ });
447
+ }
448
+
449
+ /* Read initial sort state from DOM */
450
+ const sortOrderEl = $('#sortOrder');
451
+ const sortSelectEl = $('#sortSelect');
452
+ if (sortOrderEl) currentOrder = sortOrderEl.value || currentOrder;
453
+ if (sortSelectEl) currentSort = sortSelectEl.value || currentSort;
454
+
455
+ /* Initial data load */
456
+ loadUploadsList();
457
+ loadTrashList();
458
+ loadUploadsCombined().catch(() => {});
459
+ }