vyles commited on
Commit
2590c3f
·
1 Parent(s): cca5d04

feat: enhance ui with consistent scrollbar and remove api docs tab

Browse files
Files changed (7) hide show
  1. .env +0 -0
  2. package-lock.json +43 -1
  3. package.json +4 -2
  4. public/app.js +160 -16
  5. public/index.html +36 -55
  6. public/style.css +173 -57
  7. server.js +120 -5
.env ADDED
Binary file (908 Bytes). View file
 
package-lock.json CHANGED
@@ -12,7 +12,9 @@
12
  "cors": "^2.8.5",
13
  "dotenv": "^16.3.1",
14
  "express": "^4.18.2",
15
- "multer": "^1.4.5-lts.1"
 
 
16
  }
17
  },
18
  "node_modules/accepts": {
@@ -349,6 +351,24 @@
349
  "url": "https://opencollective.com/express"
350
  }
351
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  "node_modules/finalhandler": {
353
  "version": "1.3.2",
354
  "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -505,6 +525,15 @@
505
  "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
506
  "license": "ISC"
507
  },
 
 
 
 
 
 
 
 
 
508
  "node_modules/ipaddr.js": {
509
  "version": "1.9.1",
510
  "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1093,6 +1122,19 @@
1093
  "node": ">= 0.4.0"
1094
  }
1095
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1096
  "node_modules/vary": {
1097
  "version": "1.1.2",
1098
  "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
 
12
  "cors": "^2.8.5",
13
  "dotenv": "^16.3.1",
14
  "express": "^4.18.2",
15
+ "express-rate-limit": "^8.2.1",
16
+ "multer": "^1.4.5-lts.1",
17
+ "uuid": "^13.0.0"
18
  }
19
  },
20
  "node_modules/accepts": {
 
351
  "url": "https://opencollective.com/express"
352
  }
353
  },
354
+ "node_modules/express-rate-limit": {
355
+ "version": "8.2.1",
356
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
357
+ "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
358
+ "license": "MIT",
359
+ "dependencies": {
360
+ "ip-address": "10.0.1"
361
+ },
362
+ "engines": {
363
+ "node": ">= 16"
364
+ },
365
+ "funding": {
366
+ "url": "https://github.com/sponsors/express-rate-limit"
367
+ },
368
+ "peerDependencies": {
369
+ "express": ">= 4.11"
370
+ }
371
+ },
372
  "node_modules/finalhandler": {
373
  "version": "1.3.2",
374
  "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
 
525
  "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
526
  "license": "ISC"
527
  },
528
+ "node_modules/ip-address": {
529
+ "version": "10.0.1",
530
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
531
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
532
+ "license": "MIT",
533
+ "engines": {
534
+ "node": ">= 12"
535
+ }
536
+ },
537
  "node_modules/ipaddr.js": {
538
  "version": "1.9.1",
539
  "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
 
1122
  "node": ">= 0.4.0"
1123
  }
1124
  },
1125
+ "node_modules/uuid": {
1126
+ "version": "13.0.0",
1127
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
1128
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
1129
+ "funding": [
1130
+ "https://github.com/sponsors/broofa",
1131
+ "https://github.com/sponsors/ctavan"
1132
+ ],
1133
+ "license": "MIT",
1134
+ "bin": {
1135
+ "uuid": "dist-node/bin/uuid"
1136
+ }
1137
+ },
1138
  "node_modules/vary": {
1139
  "version": "1.1.2",
1140
  "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
package.json CHANGED
@@ -15,6 +15,8 @@
15
  "cors": "^2.8.5",
16
  "dotenv": "^16.3.1",
17
  "express": "^4.18.2",
18
- "multer": "^1.4.5-lts.1"
 
 
19
  }
20
- }
 
15
  "cors": "^2.8.5",
16
  "dotenv": "^16.3.1",
17
  "express": "^4.18.2",
18
+ "express-rate-limit": "^8.2.1",
19
+ "multer": "^1.4.5-lts.1",
20
+ "uuid": "^13.0.0"
21
  }
22
+ }
public/app.js CHANGED
@@ -24,7 +24,13 @@ const elements = {
24
  closeModal: document.querySelector('.close-modal'),
25
  tabBtns: document.querySelectorAll('.tab-btn'),
26
  tabContents: document.querySelectorAll('.tab-content'),
27
- codeBlocks: document.querySelectorAll('.code-block code')
 
 
 
 
 
 
28
  };
29
 
30
  function preventDefaults(e) {
@@ -72,6 +78,11 @@ function initFileInput() {
72
  elements.fileInput.addEventListener('change', function () {
73
  handleFiles(this.files);
74
  });
 
 
 
 
 
75
  }
76
 
77
  function handleFiles(files) {
@@ -113,6 +124,9 @@ async function uploadFiles(files) {
113
  Array.from(files).forEach(file => {
114
  formData.append('files', file);
115
  });
 
 
 
116
 
117
  try {
118
  const response = await fetch('/api/upload', {
@@ -132,24 +146,20 @@ async function uploadFiles(files) {
132
  setTimeout(() => {
133
  elements.uploadStatus.textContent = 'Completed';
134
  displayResults(data.files);
 
 
135
  }, 500);
136
 
137
  } catch (error) {
138
  clearInterval(progressInterval);
139
  console.error('Upload error:', error);
140
  elements.uploadStatus.textContent = 'Failed';
141
- showStatus('Upload failed. Please try again.', 'error');
142
  } finally {
143
  elements.fileInput.value = '';
144
  }
145
  }
146
 
147
- function showStatus(message, type) {
148
- elements.statusMessage.textContent = message;
149
- elements.statusMessage.className = `status-message status-${type}`;
150
- elements.statusMessage.hidden = false;
151
- }
152
-
153
  function isImageFile(filename) {
154
  return IMAGE_EXTENSIONS.test(filename);
155
  }
@@ -182,6 +192,7 @@ function createCopyButton(url) {
182
  const btn = document.createElement('button');
183
  btn.className = 'copy-btn';
184
  btn.textContent = 'Copy';
 
185
  btn.onclick = () => copyToClipboard(url, btn);
186
  return btn;
187
  }
@@ -215,6 +226,7 @@ async function copyToClipboard(text, btn) {
215
  const originalText = btn.textContent;
216
  btn.textContent = 'Copied!';
217
  btn.classList.add('copied');
 
218
 
219
  setTimeout(() => {
220
  btn.textContent = originalText;
@@ -222,6 +234,7 @@ async function copyToClipboard(text, btn) {
222
  }, COPY_FEEDBACK_MS);
223
  } catch (err) {
224
  console.error('Failed to copy:', err);
 
225
  }
226
  }
227
 
@@ -239,6 +252,145 @@ function initTabs() {
239
  });
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  function openModal(src) {
243
  elements.modal.hidden = false;
244
  requestAnimationFrame(() => {
@@ -271,19 +423,11 @@ function initModal() {
271
  });
272
  }
273
 
274
- function initDynamicUrls() {
275
- const origin = window.location.origin;
276
- elements.codeBlocks.forEach(block => {
277
- block.innerHTML = block.innerHTML.replace(/https:\/\/YOUR_DOMAIN/g, origin);
278
- });
279
- }
280
-
281
  function init() {
282
  initDragAndDrop();
283
  initFileInput();
284
  initTabs();
285
  initModal();
286
- initDynamicUrls();
287
  }
288
 
289
  init();
 
24
  closeModal: document.querySelector('.close-modal'),
25
  tabBtns: document.querySelectorAll('.tab-btn'),
26
  tabContents: document.querySelectorAll('.tab-content'),
27
+ expirationSelect: document.getElementById('expiration'),
28
+ historyList: document.getElementById('historyList'),
29
+ clearHistoryBtn: document.getElementById('clearHistoryBtn'),
30
+ toastContainer: document.getElementById('toastContainer'),
31
+ confirmModal: document.getElementById('confirmModal'),
32
+ cancelConfirmBtn: document.getElementById('cancelConfirmBtn'),
33
+ confirmDeleteBtn: document.getElementById('confirmDeleteBtn')
34
  };
35
 
36
  function preventDefaults(e) {
 
78
  elements.fileInput.addEventListener('change', function () {
79
  handleFiles(this.files);
80
  });
81
+
82
+ // Init History
83
+ renderHistory();
84
+ elements.clearHistoryBtn.addEventListener('click', showConfirmModal);
85
+ initConfirmModal();
86
  }
87
 
88
  function handleFiles(files) {
 
124
  Array.from(files).forEach(file => {
125
  formData.append('files', file);
126
  });
127
+
128
+ // Add expiration to formData
129
+ formData.append('expiration', elements.expirationSelect.value);
130
 
131
  try {
132
  const response = await fetch('/api/upload', {
 
146
  setTimeout(() => {
147
  elements.uploadStatus.textContent = 'Completed';
148
  displayResults(data.files);
149
+ saveToHistory(data.files);
150
+ showToast('Files uploaded successfully!', 'success');
151
  }, 500);
152
 
153
  } catch (error) {
154
  clearInterval(progressInterval);
155
  console.error('Upload error:', error);
156
  elements.uploadStatus.textContent = 'Failed';
157
+ showToast('Upload failed. Please try again.', 'error');
158
  } finally {
159
  elements.fileInput.value = '';
160
  }
161
  }
162
 
 
 
 
 
 
 
163
  function isImageFile(filename) {
164
  return IMAGE_EXTENSIONS.test(filename);
165
  }
 
192
  const btn = document.createElement('button');
193
  btn.className = 'copy-btn';
194
  btn.textContent = 'Copy';
195
+ btn.setAttribute('aria-label', `Copy link for ${url}`);
196
  btn.onclick = () => copyToClipboard(url, btn);
197
  return btn;
198
  }
 
226
  const originalText = btn.textContent;
227
  btn.textContent = 'Copied!';
228
  btn.classList.add('copied');
229
+ showToast('Link copied to clipboard!', 'success');
230
 
231
  setTimeout(() => {
232
  btn.textContent = originalText;
 
234
  }, COPY_FEEDBACK_MS);
235
  } catch (err) {
236
  console.error('Failed to copy:', err);
237
+ showToast('Failed to copy link', 'error');
238
  }
239
  }
240
 
 
252
  });
253
  }
254
 
255
+ // History Management
256
+ function getHistory() {
257
+ try {
258
+ return JSON.parse(localStorage.getItem('uploadHistory') || '[]');
259
+ } catch {
260
+ return [];
261
+ }
262
+ }
263
+
264
+ function saveToHistory(newFiles) {
265
+ const history = getHistory();
266
+ const updatedHistory = [...newFiles, ...history].slice(0, 50); // Keep last 50
267
+ localStorage.setItem('uploadHistory', JSON.stringify(updatedHistory));
268
+ renderHistory();
269
+ }
270
+
271
+ function showConfirmModal() {
272
+ elements.confirmModal.hidden = false;
273
+ requestAnimationFrame(() => elements.confirmModal.classList.add('show'));
274
+ }
275
+
276
+ function hideConfirmModal() {
277
+ elements.confirmModal.classList.remove('show');
278
+ setTimeout(() => {
279
+ elements.confirmModal.hidden = true;
280
+ }, 300);
281
+ }
282
+
283
+ function initConfirmModal() {
284
+ elements.cancelConfirmBtn.onclick = hideConfirmModal;
285
+
286
+ elements.confirmDeleteBtn.onclick = () => {
287
+ localStorage.removeItem('uploadHistory');
288
+ renderHistory();
289
+ hideConfirmModal();
290
+ showToast('History cleared', 'success');
291
+ };
292
+
293
+ elements.confirmModal.addEventListener('click', (e) => {
294
+ if (e.target === elements.confirmModal) hideConfirmModal();
295
+ });
296
+ }
297
+
298
+ function renderHistory() {
299
+ const history = getHistory();
300
+ elements.historyList.innerHTML = '';
301
+
302
+ if (history.length === 0) {
303
+ elements.historyList.innerHTML = '<p class="empty-state" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No upload history yet.</p>';
304
+ return;
305
+ }
306
+
307
+ history.forEach(file => {
308
+ const item = document.createElement('div');
309
+ item.className = 'result-item'; // Reuse result item styling
310
+
311
+ if (isImageFile(file.originalName)) {
312
+ // Create small preview
313
+ const trigger = document.createElement('div');
314
+ trigger.className = 'preview-link';
315
+ trigger.style.cursor = 'pointer';
316
+ const img = document.createElement('img');
317
+ img.src = file.url;
318
+ img.className = 'result-preview';
319
+ trigger.appendChild(img);
320
+ trigger.setAttribute('role', 'button');
321
+ trigger.setAttribute('aria-label', `Preview ${file.originalName}`);
322
+ trigger.setAttribute('tabindex', '0');
323
+ trigger.onkeypress = (e) => {
324
+ if (e.key === 'Enter') openModal(file.url);
325
+ };
326
+ trigger.onclick = () => openModal(file.url);
327
+ item.appendChild(trigger);
328
+ }
329
+
330
+ const content = document.createElement('div');
331
+ content.className = 'result-content';
332
+
333
+ // Wrapper for link and expiration info
334
+ const infoWrapper = document.createElement('div');
335
+ infoWrapper.style.flex = '1';
336
+ infoWrapper.style.minWidth = '0';
337
+ infoWrapper.style.display = 'flex';
338
+ infoWrapper.style.flexDirection = 'column';
339
+
340
+ infoWrapper.appendChild(createFileLink(file));
341
+
342
+ if (file.expiresAt) {
343
+ const expirationSpan = document.createElement('span');
344
+ expirationSpan.style.fontSize = '0.75rem';
345
+ expirationSpan.style.color = 'var(--text-muted)';
346
+ expirationSpan.style.marginTop = '4px';
347
+
348
+ const expDate = new Date(file.expiresAt);
349
+ const now = new Date();
350
+ const isExpired = expDate < now;
351
+
352
+ if (isExpired) {
353
+ expirationSpan.textContent = 'Expired';
354
+ expirationSpan.style.color = 'var(--error-color)';
355
+ } else {
356
+ expirationSpan.textContent = `Expires: ${expDate.toLocaleString()}`;
357
+ }
358
+
359
+ infoWrapper.appendChild(expirationSpan);
360
+ } else {
361
+ const permanentSpan = document.createElement('span');
362
+ permanentSpan.style.fontSize = '0.75rem';
363
+ permanentSpan.style.color = 'var(--success-color)';
364
+ permanentSpan.style.marginTop = '4px';
365
+ permanentSpan.textContent = 'Permanent';
366
+ infoWrapper.appendChild(permanentSpan);
367
+ }
368
+
369
+ content.appendChild(infoWrapper);
370
+ content.appendChild(createCopyButton(file.url));
371
+ item.appendChild(content);
372
+ elements.historyList.appendChild(item);
373
+ });
374
+ }
375
+
376
+ // Toast Notification System
377
+ function showToast(message, type = 'info') {
378
+ const toast = document.createElement('div');
379
+ toast.className = `toast ${type}`;
380
+
381
+ toast.innerHTML = `
382
+ <span class="toast-message">${message}</span>
383
+ `;
384
+
385
+ elements.toastContainer.appendChild(toast);
386
+
387
+ // Remove after 3 seconds
388
+ setTimeout(() => {
389
+ toast.classList.add('hiding');
390
+ toast.addEventListener('animationend', () => toast.remove());
391
+ }, 3000);
392
+ }
393
+
394
  function openModal(src) {
395
  elements.modal.hidden = false;
396
  requestAnimationFrame(() => {
 
423
  });
424
  }
425
 
 
 
 
 
 
 
 
426
  function init() {
427
  initDragAndDrop();
428
  initFileInput();
429
  initTabs();
430
  initModal();
 
431
  }
432
 
433
  init();
public/index.html CHANGED
@@ -27,7 +27,7 @@
27
  <!-- Tabs -->
28
  <div class="tabs">
29
  <button class="tab-btn active" data-tab="upload">Upload</button>
30
- <button class="tab-btn" data-tab="docs">API Docs</button>
31
  </div>
32
 
33
  <!-- Upload Tab Content -->
@@ -48,10 +48,20 @@
48
  </svg>
49
  </div>
50
  <h3>Drag & Drop files here</h3>
51
- <p>or <button class="browse-btn" id="browseBtn">Browse Files</button></p>
52
  <input type="file" id="fileInput" multiple hidden>
53
  </div>
54
 
 
 
 
 
 
 
 
 
 
 
55
  <div class="progress-container" id="progressContainer" hidden>
56
  <div class="file-info">
57
  <span class="file-name" id="fileName">Uploading files...</span>
@@ -66,59 +76,19 @@
66
  <div class="status-message" id="statusMessage" hidden></div>
67
  </div>
68
 
69
- <!-- API Docs Tab Content -->
70
- <div class="tab-content" id="docs">
71
  <div class="card-header">
72
- <h1>API Documentation</h1>
73
- <p>Integrate file uploads into your application</p>
74
- </div>
75
-
76
- <div class="docs-section">
77
- <h3>Endpoint</h3>
78
- <div class="code-block">
79
- <pre><code class="language-http">POST /api/upload</code></pre>
80
- </div>
81
  </div>
82
-
83
- <div class="docs-section">
84
- <h3>Request</h3>
85
- <p class="docs-text">Send files using <code>multipart/form-data</code> with the field name
86
- <code>files</code>.
87
- </p>
88
- <div class="code-block">
89
- <pre><code class="language-bash">curl -X POST \
90
- https://YOUR_DOMAIN/api/upload \
91
92
- -F "[email protected]"</code></pre>
93
- </div>
94
  </div>
95
 
96
- <div class="docs-section">
97
- <h3>Response</h3>
98
- <p class="docs-text">Returns JSON with uploaded file details:</p>
99
- <div class="code-block">
100
- <pre><code class="language-json">{
101
- "status": "success",
102
- "message": "Files uploaded successfully",
103
- "files": [
104
- {
105
- "originalName": "image1.png",
106
- "filename": "1733...-image1.png",
107
- "url": "https://YOUR_DOMAIN/uploads/..."
108
- }
109
- ]
110
- }</code></pre>
111
- </div>
112
- </div>
113
-
114
- <div class="docs-section">
115
- <h3>Error Response</h3>
116
- <div class="code-block">
117
- <pre><code class="language-json">{
118
- "status": "error",
119
- "message": "No files uploaded"
120
- }</code></pre>
121
- </div>
122
  </div>
123
  </div>
124
  </div>
@@ -129,11 +99,22 @@
129
  <img class="modal-content" id="modalImage">
130
  </div>
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  <script src="app.js" type="module"></script>
133
- <!-- Prism.js -->
134
- <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
135
- <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
136
- <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
137
  </body>
138
 
139
  </html>
 
27
  <!-- Tabs -->
28
  <div class="tabs">
29
  <button class="tab-btn active" data-tab="upload">Upload</button>
30
+ <button class="tab-btn" data-tab="history">History</button>
31
  </div>
32
 
33
  <!-- Upload Tab Content -->
 
48
  </svg>
49
  </div>
50
  <h3>Drag & Drop files here</h3>
51
+ <p>or <button class="browse-btn" id="browseBtn" aria-label="Browse files to upload">Browse Files</button></p>
52
  <input type="file" id="fileInput" multiple hidden>
53
  </div>
54
 
55
+ <div class="upload-options" style="margin-top: 1.5rem; display: flex; align-items: center; gap: 1rem;">
56
+ <label for="expiration" style="font-weight: 500; color: var(--text-muted);">File Expiration:</label>
57
+ <select id="expiration" aria-label="Select file expiration time" style="padding: 0.5rem; border-radius: 8px; border: 1px solid #ccc; background: rgba(255,255,255,0.8); color: var(--text-color);">
58
+ <option value="1h">1 Hour</option>
59
+ <option value="24h" selected>24 Hours</option>
60
+ <option value="7d">7 Days</option>
61
+ <option value="permanent">Never (Permanent)</option>
62
+ </select>
63
+ </div>
64
+
65
  <div class="progress-container" id="progressContainer" hidden>
66
  <div class="file-info">
67
  <span class="file-name" id="fileName">Uploading files...</span>
 
76
  <div class="status-message" id="statusMessage" hidden></div>
77
  </div>
78
 
79
+ <!-- History Tab Content -->
80
+ <div class="tab-content" id="history">
81
  <div class="card-header">
82
+ <h1>Upload History</h1>
83
+ <p>Your previously uploaded files</p>
 
 
 
 
 
 
 
84
  </div>
85
+
86
+ <div class="history-actions" style="margin-bottom: 1rem; text-align: right;">
87
+ <button id="clearHistoryBtn" class="secondary-btn" style="padding: 0.6rem 1.2rem; border: 1px solid #ccc; border-radius: 8px; background: rgba(255, 255, 255, 0.9); color: var(--text-color); cursor: pointer; font-weight: 500; transition: all 0.2s ease;">Clear History</button>
 
 
 
 
 
 
 
 
 
88
  </div>
89
 
90
+ <div id="historyList" class="history-list">
91
+ <p class="empty-state" style="text-align: center; color: var(--text-muted); padding: 2rem;">No upload history yet.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
  </div>
94
  </div>
 
99
  <img class="modal-content" id="modalImage">
100
  </div>
101
 
102
+ <!-- Confirmation Modal -->
103
+ <div id="confirmModal" class="modal" hidden>
104
+ <div class="confirm-content">
105
+ <h3>Clear History?</h3>
106
+ <p>Are you sure you want to clear your entire upload history? This action cannot be undone.</p>
107
+ <div class="confirm-actions">
108
+ <button id="cancelConfirmBtn" class="secondary-btn">Cancel</button>
109
+ <button id="confirmDeleteBtn" class="danger-btn">Delete History</button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <!-- Toast Container -->
115
+ <div id="toastContainer" class="toast-container"></div>
116
+
117
  <script src="app.js" type="module"></script>
 
 
 
 
118
  </body>
119
 
120
  </html>
public/style.css CHANGED
@@ -7,8 +7,8 @@
7
  --bg-gradient-2: #6366f1;
8
  --bg-gradient-3: #f472b6;
9
  --glass-bg: rgba(255, 255, 255, 0.7);
10
- --glass-border: rgba(255, 255, 255, 0.5);
11
- --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
12
  --success-color: #10b981;
13
  --error-color: #ef4444;
14
  --transition-fast: 0.2s ease;
@@ -104,8 +104,8 @@ body {
104
 
105
  .upload-card {
106
  background: var(--glass-bg);
107
- backdrop-filter: blur(12px);
108
- -webkit-backdrop-filter: blur(12px);
109
  border: 1px solid var(--glass-border);
110
  border-radius: var(--border-radius-2xl);
111
  padding: 40px;
@@ -251,7 +251,14 @@ body {
251
  .results-container {
252
  margin-top: 24px;
253
  text-align: left;
254
- max-height: 40vh;
 
 
 
 
 
 
 
255
  overflow-y: auto;
256
  }
257
 
@@ -358,6 +365,24 @@ body {
358
  background: rgba(107, 114, 128, 0.5);
359
  }
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  /* Tabs */
362
  .tabs {
363
  display: flex;
@@ -379,15 +404,27 @@ body {
379
  color: var(--text-muted);
380
  cursor: pointer;
381
  transition: all var(--transition-normal);
 
 
382
  }
383
 
384
  .tab-btn:hover {
385
  color: var(--text-color);
 
 
 
 
 
 
 
 
 
 
386
  }
387
 
388
  .tab-btn.active {
389
- background: white;
390
- color: var(--primary-color);
391
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
392
  }
393
 
@@ -412,56 +449,6 @@ body {
412
  }
413
  }
414
 
415
- /* API Docs */
416
- .docs-section {
417
- margin-bottom: 20px;
418
- text-align: left;
419
- }
420
-
421
- .docs-section h3 {
422
- font-size: 1rem;
423
- font-weight: 600;
424
- color: var(--text-color);
425
- margin-bottom: 8px;
426
- }
427
-
428
- .docs-text {
429
- font-size: 0.9rem;
430
- color: var(--text-muted);
431
- margin-bottom: 8px;
432
- line-height: 1.5;
433
- }
434
-
435
- .docs-text code {
436
- background: rgba(79, 70, 229, 0.1);
437
- color: var(--primary-color);
438
- padding: 2px 6px;
439
- border-radius: var(--border-radius-sm);
440
- font-family: 'Fira Code', monospace;
441
- font-size: 0.85rem;
442
- }
443
-
444
- .code-block {
445
- background: #2d2d2d;
446
- border-radius: var(--border-radius-lg);
447
- padding: 16px;
448
- overflow-x: auto;
449
- }
450
-
451
- .code-block code,
452
- .code-block pre {
453
- font-family: 'Fira Code', monospace;
454
- font-size: 0.85rem;
455
- margin: 0;
456
- background: transparent !important;
457
- }
458
-
459
- .code-block pre[class*="language-"] {
460
- margin: 0;
461
- padding: 0;
462
- background: transparent;
463
- }
464
-
465
  /* Modal */
466
  .modal {
467
  position: fixed;
@@ -514,4 +501,133 @@ body {
514
  .close-modal:hover,
515
  .close-modal:focus {
516
  color: #bbb;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  }
 
7
  --bg-gradient-2: #6366f1;
8
  --bg-gradient-3: #f472b6;
9
  --glass-bg: rgba(255, 255, 255, 0.7);
10
+ --glass-border: rgba(255, 255, 255, 0.8);
11
+ --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.2);
12
  --success-color: #10b981;
13
  --error-color: #ef4444;
14
  --transition-fast: 0.2s ease;
 
104
 
105
  .upload-card {
106
  background: var(--glass-bg);
107
+ backdrop-filter: blur(20px);
108
+ -webkit-backdrop-filter: blur(20px);
109
  border: 1px solid var(--glass-border);
110
  border-radius: var(--border-radius-2xl);
111
  padding: 40px;
 
251
  .results-container {
252
  margin-top: 24px;
253
  text-align: left;
254
+ max-height: 350px;
255
+ overflow-y: auto;
256
+ }
257
+
258
+ .history-list {
259
+ margin-top: 24px;
260
+ text-align: left;
261
+ max-height: 350px;
262
  overflow-y: auto;
263
  }
264
 
 
365
  background: rgba(107, 114, 128, 0.5);
366
  }
367
 
368
+ /* History List Scrollbar - Match Results Container */
369
+ .history-list::-webkit-scrollbar {
370
+ width: 6px;
371
+ }
372
+
373
+ .history-list::-webkit-scrollbar-track {
374
+ background: transparent;
375
+ }
376
+
377
+ .history-list::-webkit-scrollbar-thumb {
378
+ background: rgba(156, 163, 175, 0.5);
379
+ border-radius: 3px;
380
+ }
381
+
382
+ .history-list::-webkit-scrollbar-thumb:hover {
383
+ background: rgba(107, 114, 128, 0.5);
384
+ }
385
+
386
  /* Tabs */
387
  .tabs {
388
  display: flex;
 
404
  color: var(--text-muted);
405
  cursor: pointer;
406
  transition: all var(--transition-normal);
407
+ position: relative;
408
+ overflow: hidden;
409
  }
410
 
411
  .tab-btn:hover {
412
  color: var(--text-color);
413
+ background: rgba(255, 255, 255, 0.4);
414
+ }
415
+
416
+ /* Focus Visible for Accessibility */
417
+ .tab-btn:focus-visible,
418
+ .browse-btn:focus-visible,
419
+ .copy-btn:focus-visible,
420
+ select:focus-visible {
421
+ outline: 2px solid var(--primary-color);
422
+ outline-offset: 2px;
423
  }
424
 
425
  .tab-btn.active {
426
+ background: linear-gradient(135deg, var(--bg-gradient-2), var(--bg-gradient-1));
427
+ color: white;
428
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
429
  }
430
 
 
449
  }
450
  }
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  /* Modal */
453
  .modal {
454
  position: fixed;
 
501
  .close-modal:hover,
502
  .close-modal:focus {
503
  color: #bbb;
504
+ }
505
+
506
+ /* Confirmation Modal */
507
+ .confirm-content {
508
+ background: white;
509
+ padding: 32px;
510
+ border-radius: var(--border-radius-xl);
511
+ max-width: 400px;
512
+ width: 90%;
513
+ text-align: center;
514
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2);
515
+ animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
516
+ }
517
+
518
+ .confirm-content h3 {
519
+ font-size: 1.5rem;
520
+ margin-bottom: 12px;
521
+ color: var(--text-color);
522
+ }
523
+
524
+ .confirm-content p {
525
+ color: var(--text-muted);
526
+ margin-bottom: 24px;
527
+ line-height: 1.5;
528
+ }
529
+
530
+ .confirm-actions {
531
+ display: flex;
532
+ gap: 12px;
533
+ justify-content: center;
534
+ }
535
+
536
+ .secondary-btn {
537
+ padding: 10px 20px;
538
+ border: 1px solid #ccc;
539
+ border-radius: var(--border-radius-md);
540
+ background: white;
541
+ color: var(--text-color);
542
+ cursor: pointer;
543
+ font-weight: 500;
544
+ transition: all 0.2s;
545
+ }
546
+
547
+ .secondary-btn:hover {
548
+ background: #f3f4f6;
549
+ border-color: #b0b0b0;
550
+ }
551
+
552
+ .danger-btn {
553
+ padding: 10px 20px;
554
+ border: none;
555
+ border-radius: var(--border-radius-md);
556
+ background: var(--error-color);
557
+ color: white;
558
+ cursor: pointer;
559
+ font-weight: 500;
560
+ transition: background 0.2s;
561
+ }
562
+
563
+ .danger-btn:hover {
564
+ background: #dc2626;
565
+ }
566
+
567
+ @keyframes popIn {
568
+ from { transform: scale(0.9); opacity: 0; }
569
+ to { transform: scale(1); opacity: 1; }
570
+ }
571
+
572
+ /* Toast Notifications */
573
+ .toast-container {
574
+ position: fixed;
575
+ bottom: 24px;
576
+ right: 24px;
577
+ display: flex;
578
+ flex-direction: column;
579
+ gap: 12px;
580
+ z-index: 2000;
581
+ pointer-events: none;
582
+ }
583
+
584
+ .toast {
585
+ background: white;
586
+ color: var(--text-color);
587
+ padding: 12px 20px;
588
+ border-radius: var(--border-radius-md);
589
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
590
+ display: flex;
591
+ align-items: center;
592
+ gap: 12px;
593
+ min-width: 250px;
594
+ pointer-events: auto;
595
+ animation: slideInRight 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
596
+ border-left: 4px solid var(--primary-color);
597
+ }
598
+
599
+ .toast.success { border-left-color: var(--success-color); }
600
+ .toast.error { border-left-color: var(--error-color); }
601
+
602
+ .toast.hiding {
603
+ animation: fadeOutRight 0.3s ease forwards;
604
+ }
605
+
606
+ @keyframes slideInRight {
607
+ from { transform: translateX(100%); opacity: 0; }
608
+ to { transform: translateX(0); opacity: 1; }
609
+ }
610
+
611
+ @keyframes fadeOutRight {
612
+ from { transform: translateX(0); opacity: 1; }
613
+ to { transform: translateX(100%); opacity: 0; }
614
+ }
615
+
616
+ /* Mobile Responsiveness */
617
+ @media (max-width: 480px) {
618
+ .container {
619
+ padding: 10px;
620
+ }
621
+
622
+ .upload-card {
623
+ padding: 24px;
624
+ }
625
+
626
+ .tabs {
627
+ flex-wrap: wrap;
628
+ }
629
+
630
+ .tab-btn {
631
+ flex-basis: 100%;
632
+ }
633
  }
server.js CHANGED
@@ -11,16 +11,26 @@ import os from 'os';
11
  import { fileURLToPath } from 'url';
12
  import fs from 'fs/promises';
13
  import { existsSync, mkdirSync } from 'fs';
 
 
 
 
 
14
 
15
  const __filename = fileURLToPath(import.meta.url);
16
  const __dirname = path.dirname(__filename);
17
 
18
  const CONFIG = {
19
  port: process.env.PORT || 7860,
20
- uploadDir: path.join(os.tmpdir(), 'web-uploader'),
21
- maxFileSize: 500 * 1024 * 1024
 
 
 
22
  };
23
 
 
 
24
  const app = express();
25
 
26
  app.set('trust proxy', true);
@@ -28,6 +38,15 @@ app.use(cors());
28
  app.use(express.static(path.join(__dirname, 'public')));
29
  app.use('/uploads', express.static(CONFIG.uploadDir));
30
 
 
 
 
 
 
 
 
 
 
31
  const storage = multer.diskStorage({
32
  destination: (req, file, cb) => {
33
  if (!existsSync(CONFIG.uploadDir)) {
@@ -46,6 +65,89 @@ const upload = multer({
46
  limits: { fileSize: CONFIG.maxFileSize }
47
  });
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  function isValidFilePath(filePath) {
50
  const safeUploadDir = path.normalize(CONFIG.uploadDir);
51
  const resolvedPath = path.normalize(filePath);
@@ -56,7 +158,7 @@ function buildFileUrl(req, filename) {
56
  return `${req.protocol}://${req.get('host')}/uploads/${filename}`;
57
  }
58
 
59
- app.post('/api/upload', upload.array('files'), (req, res) => {
60
  if (!req.files || req.files.length === 0) {
61
  return res.status(400).json({
62
  status: 'error',
@@ -64,10 +166,16 @@ app.post('/api/upload', upload.array('files'), (req, res) => {
64
  });
65
  }
66
 
67
- const uploadedFiles = req.files.map(file => ({
 
 
 
 
68
  originalName: file.originalname,
69
  filename: file.filename,
70
- url: buildFileUrl(req, file.filename)
 
 
71
  }));
72
 
73
  res.status(200).json({
@@ -89,6 +197,13 @@ app.delete('/api/delete/:filename', async (req, res) => {
89
  }
90
 
91
  try {
 
 
 
 
 
 
 
92
  await fs.access(filePath);
93
  } catch {
94
  return res.status(404).json({
 
11
  import { fileURLToPath } from 'url';
12
  import fs from 'fs/promises';
13
  import { existsSync, mkdirSync } from 'fs';
14
+ import dotenv from 'dotenv';
15
+ import rateLimit from 'express-rate-limit';
16
+ import { v4 as uuidv4 } from 'uuid';
17
+
18
+ dotenv.config();
19
 
20
  const __filename = fileURLToPath(import.meta.url);
21
  const __dirname = path.dirname(__filename);
22
 
23
  const CONFIG = {
24
  port: process.env.PORT || 7860,
25
+ uploadDir: process.env.UPLOAD_DIR || path.join(os.tmpdir(), 'web-uploader'),
26
+ maxFileSize: parseInt(process.env.MAX_FILE_SIZE) || 500 * 1024 * 1024,
27
+ rateLimitWindow: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
28
+ rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX) || 100,
29
+ cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL_MS) || 60 * 60 * 1000 // 1 hour
30
  };
31
 
32
+ const METADATA_FILE = path.join(CONFIG.uploadDir, 'metadata.json');
33
+
34
  const app = express();
35
 
36
  app.set('trust proxy', true);
 
38
  app.use(express.static(path.join(__dirname, 'public')));
39
  app.use('/uploads', express.static(CONFIG.uploadDir));
40
 
41
+ // Rate Limiting
42
+ const limiter = rateLimit({
43
+ windowMs: CONFIG.rateLimitWindow,
44
+ max: CONFIG.rateLimitMax,
45
+ standardHeaders: true,
46
+ legacyHeaders: false,
47
+ validate: { trustProxy: false }
48
+ });
49
+
50
  const storage = multer.diskStorage({
51
  destination: (req, file, cb) => {
52
  if (!existsSync(CONFIG.uploadDir)) {
 
65
  limits: { fileSize: CONFIG.maxFileSize }
66
  });
67
 
68
+ // Metadata Manager
69
+ class MetadataManager {
70
+ static async ensureMetadataFile() {
71
+ if (!existsSync(METADATA_FILE)) {
72
+ await fs.writeFile(METADATA_FILE, JSON.stringify({}, null, 2));
73
+ }
74
+ }
75
+
76
+ static async load() {
77
+ await this.ensureMetadataFile();
78
+ try {
79
+ const data = await fs.readFile(METADATA_FILE, 'utf8');
80
+ return JSON.parse(data);
81
+ } catch (error) {
82
+ console.error('Error reading metadata:', error);
83
+ return {};
84
+ }
85
+ }
86
+
87
+ static async save(data) {
88
+ await fs.writeFile(METADATA_FILE, JSON.stringify(data, null, 2));
89
+ }
90
+
91
+ static async addFile(filename, expiration) {
92
+ const metadata = await this.load();
93
+ const expiresAt = expiration === 'permanent' ? null : Date.now() + this.parseExpiration(expiration);
94
+
95
+ metadata[filename] = {
96
+ filename,
97
+ uploadedAt: Date.now(),
98
+ expiresAt
99
+ };
100
+
101
+ await this.save(metadata);
102
+ return metadata[filename];
103
+ }
104
+
105
+ static parseExpiration(exp) {
106
+ const units = {
107
+ 'h': 60 * 60 * 1000,
108
+ 'd': 24 * 60 * 60 * 1000,
109
+ 'w': 7 * 24 * 60 * 60 * 1000
110
+ };
111
+ const match = exp.match(/^(\d+)([hdw])$/);
112
+ if (!match) return 24 * 60 * 60 * 1000; // Default 24h
113
+ return parseInt(match[1]) * units[match[2]];
114
+ }
115
+
116
+ static async cleanup() {
117
+ const metadata = await this.load();
118
+ const now = Date.now();
119
+ let changed = false;
120
+
121
+ for (const [filename, info] of Object.entries(metadata)) {
122
+ if (info.expiresAt && info.expiresAt < now) {
123
+ try {
124
+ const filePath = path.join(CONFIG.uploadDir, filename);
125
+ if (existsSync(filePath)) {
126
+ await fs.unlink(filePath);
127
+ console.log(`Expired file deleted: ${filename}`);
128
+ }
129
+ delete metadata[filename];
130
+ changed = true;
131
+ } catch (err) {
132
+ console.error(`Failed to delete expired file ${filename}:`, err);
133
+ }
134
+ }
135
+ }
136
+
137
+ if (changed) {
138
+ await this.save(metadata);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Start cleanup task
144
+ setInterval(() => {
145
+ MetadataManager.cleanup();
146
+ }, CONFIG.cleanupInterval);
147
+
148
+ // Initial cleanup on startup
149
+ MetadataManager.cleanup();
150
+
151
  function isValidFilePath(filePath) {
152
  const safeUploadDir = path.normalize(CONFIG.uploadDir);
153
  const resolvedPath = path.normalize(filePath);
 
158
  return `${req.protocol}://${req.get('host')}/uploads/${filename}`;
159
  }
160
 
161
+ app.post('/api/upload', limiter, upload.array('files'), async (req, res) => {
162
  if (!req.files || req.files.length === 0) {
163
  return res.status(400).json({
164
  status: 'error',
 
166
  });
167
  }
168
 
169
+ const expiration = req.body.expiration || '24h';
170
+
171
+ const uploadedFiles = await Promise.all(req.files.map(async file => {
172
+ const metadata = await MetadataManager.addFile(file.filename, expiration);
173
+ return {
174
  originalName: file.originalname,
175
  filename: file.filename,
176
+ url: buildFileUrl(req, file.filename),
177
+ expiresAt: metadata.expiresAt
178
+ };
179
  }));
180
 
181
  res.status(200).json({
 
197
  }
198
 
199
  try {
200
+ // Remove from metadata
201
+ const metadata = await MetadataManager.load();
202
+ if (metadata[filename]) {
203
+ delete metadata[filename];
204
+ await MetadataManager.save(metadata);
205
+ }
206
+
207
  await fs.access(filePath);
208
  } catch {
209
  return res.status(404).json({