index.html (25832B)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Upload Hub</title> 7 <link rel="preconnect" href="https://fonts.googleapis.com"> 8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 10 <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,1,0" rel="stylesheet" /> 11 <script src="https://cdn.tailwindcss.com"></script> 12 <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> 13 <script> 14 tailwind.config = { 15 theme: { 16 extend: { 17 fontFamily: { sans: ['Lexend', 'sans-serif'] }, 18 colors: { 19 m3: { 20 background: '#FDFCFB', backgroundDark: '#1A1C19', 21 surface: '#FDFCFB', surfaceDark: '#1A1C19', 22 surfaceContainer: '#F1F1EA', surfaceContainerDark: '#2D2F2B', 23 surfaceContainerHigh: '#EBEBE4', surfaceContainerHighDark: '#333531', 24 surfaceContainerHighest: '#E5E6DE', surfaceContainerHighestDark: '#434541', 25 onSurface: '#1A1C19', onSurfaceDark: '#E3E3DC', 26 onSurfaceVariant: '#43483E', onSurfaceVariantDark: '#C3C8BC', 27 primary: '#386A20', primaryDark: '#9CD67D', 28 onPrimary: '#FFFFFF', onPrimaryDark: '#0C3900', 29 secondaryContainer: '#D7E8CD', secondaryContainerDark: '#374B2E', 30 onSecondaryContainer: '#111F0F', onSecondaryContainerDark: '#D7E8CD', 31 error: '#BA1A1A', errorDark: '#FFB4AB', 32 errorContainer: '#FFDAD6', errorContainerDark: '#93000A', 33 onErrorContainer: '#410002', onErrorContainerDark: '#FFDAD6', 34 outline: '#74796D', outlineDark: '#8D9286', 35 } 36 } 37 } 38 } 39 } 40 </script> 41 <style> 42 body { background-color: #FDFCFB; margin: 0; -webkit-font-smoothing: antialiased; } 43 @media (prefers-color-scheme: dark) { body { background-color: #1A1C19; } } 44 input:focus, select:focus { outline: none; border-bottom-color: #386A20; } 45 @media (prefers-color-scheme: dark) { input:focus, select:focus { border-bottom-color: #9CD67D; } } 46 .show { display: block !important; } 47 .show-flex { display: flex !important; } 48 .hide { display: none !important; } 49 .tab-active { border-bottom: 2px solid #386A20; color: #386A20; } 50 @media (prefers-color-scheme: dark) { .tab-active { border-bottom-color: #9CD67D; color: #9CD67D; } } 51 52 /* Copy button success feedback */ 53 .copy-btn .copy-icon { display: inline-block; transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } 54 .copy-btn.copied .copy-icon { transform: scale(1.25); } 55 .copy-btn.copied { background-color: #2E7D32 !important; } 56 </style> 57 </head> 58 <body class="bg-m3-background dark:bg-m3-backgroundDark text-m3-onSurface dark:text-m3-onSurfaceDark flex flex-col items-center min-h-screen p-4 font-sans"> 59 60 <div id="ui-container" class="w-full max-w-[520px] mt-8"> 61 <div class="mb-6 flex items-end justify-between"> 62 <div> 63 <h1 class="text-2xl font-bold tracking-tight">Upload</h1> 64 <p class="text-sm text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mt-1">Store files permanently on Hugging Face<br><br><a href="/docs">• <b><u>Read Docs</u></b></a></p> 65 </div> 66 </div> 67 68 <!-- Tabs --> 69 <div class="flex gap-6 mb-6 border-b border-m3-surfaceContainerHighest dark:border-m3-surfaceContainerHighestDark"> 70 <button data-tab="upload-tab" class="tab-btn tab-active pb-2 text-sm font-semibold">Upload</button> 71 <button data-tab="url-tab" class="tab-btn pb-2 text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">From URL</button> 72 </div> 73 74 <!-- ==================== UPLOAD TAB ==================== --> 75 <div id="upload-tab" class="tab-panel"> 76 <div class="flex flex-col gap-1.5 mb-6"> 77 <label for="custom-id" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Custom ID (single file only, optional)</label> 78 <input type="text" id="custom-id" placeholder="e.g., source-code" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> 79 </div> 80 81 <div id="dropzone" class="bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark hover:bg-m3-surfaceContainerHigh dark:hover:bg-m3-surfaceContainerHighDark rounded-[24px] p-10 flex flex-col items-center justify-center cursor-pointer mb-6 border border-transparent transition-colors"> 82 <span class="material-symbols-outlined text-[48px] text-m3-primary dark:text-m3-primaryDark mb-4">upload</span> 83 <span class="text-base font-semibold">Click, drag, or paste (Ctrl+V) files</span> 84 <span class="text-sm mt-1 text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Multiple files supported · any type</span> 85 <span class="text-sm mt-1 text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Maximum 4.4 MB each batch</span> 86 <input type="file" id="file-input" class="hidden" multiple> 87 </div> 88 89 <div id="progress-container" class="hide mb-6"> 90 <div class="flex justify-between items-center mb-2"> 91 <span id="progress-label" class="text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Uploading to browser...</span> 92 <span id="progress-text" class="text-sm font-bold text-m3-primary dark:text-m3-primaryDark">0%</span> 93 </div> 94 <div class="w-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark h-2 rounded-none overflow-hidden relative"> 95 <div id="progress-bar" class="bg-m3-primary dark:bg-m3-primaryDark h-2 rounded-none" style="width: 0%;"></div> 96 </div> 97 </div> 98 99 <div id="processing-container" class="hide animate-pulse bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark p-4 rounded-[16px] mb-6"> 100 <div class="flex items-center gap-2 mb-3"> 101 <div class="w-5 h-5 rounded-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 102 <div class="w-32 h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 103 </div> 104 <div class="bg-m3-background dark:bg-m3-backgroundDark p-3 rounded-[8px] flex flex-col gap-3"> 105 <div class="w-full h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 106 <div class="w-3/4 h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 107 <div class="flex gap-2 w-full mt-1"> 108 <div class="flex-1 h-9 rounded-[8px] bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 109 <div class="flex-1 h-9 rounded-[8px] bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> 110 </div> 111 </div> 112 </div> 113 114 <div id="results-list" class="flex flex-col gap-3 mb-6"></div> 115 116 <div id="error-container" class="hide bg-m3-errorContainer dark:bg-m3-errorContainerDark text-m3-onErrorContainer dark:text-m3-onErrorContainerDark p-4 rounded-[16px] mb-6"> 117 <div class="flex items-start gap-3"> 118 <span class="material-symbols-outlined">error</span> 119 <span id="error-text" class="text-sm font-medium mt-0.5"></span> 120 </div> 121 </div> 122 </div> 123 124 <!-- ==================== URL TAB ==================== --> 125 <div id="url-tab" class="tab-panel hide"> 126 <div class="flex flex-col gap-1.5 mb-4"> 127 <label for="url-input" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">File URL</label> 128 <input type="text" id="url-input" placeholder="https://example.com/file.png" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> 129 </div> 130 131 <div class="flex flex-col gap-1.5 mb-6"> 132 <label for="url-custom-id" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Custom ID (optional)</label> 133 <input type="text" id="url-custom-id" placeholder="e.g., logo" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> 134 </div> 135 136 <button id="url-upload-btn" class="w-full bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-4 py-3 rounded-[8px] text-sm font-semibold mb-6">Fetch & Upload</button> 137 138 <div id="url-results-list" class="flex flex-col gap-3 mb-6"></div> 139 140 <div id="url-error-container" class="hide bg-m3-errorContainer dark:bg-m3-errorContainerDark text-m3-onErrorContainer dark:text-m3-onErrorContainerDark p-4 rounded-[16px] mb-6"> 141 <div class="flex items-start gap-3"> 142 <span class="material-symbols-outlined">error</span> 143 <span id="url-error-text" class="text-sm font-medium mt-0.5"></span> 144 </div> 145 </div> 146 </div> 147 148 </div> 149 150 <!-- QR modal --> 151 <div id="qr-modal" class="hide fixed inset-0 bg-black/50 items-center justify-center z-50 p-4"> 152 <div class="bg-m3-surface dark:bg-m3-surfaceDark rounded-[24px] p-6 max-w-[320px] w-full flex flex-col items-center gap-4"> 153 <div id="qr-code-box" class="bg-white p-3 rounded-[8px]"></div> 154 <div class="flex gap-2 w-full"> 155 <button id="qr-download-btn" class="flex-1 bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-3 py-2 rounded-[8px] text-sm font-medium">Download PNG</button> 156 <button id="qr-close-btn" class="flex-1 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium">Close</button> 157 </div> 158 </div> 159 </div> 160 161 <div class="w-full text-center text-xs font-medium text-m3-outline dark:text-m3-outlineDark mt-10 mb-4"> 162 © 2026 <a href="https://github.com/notamitgamer">notamitgamer</a> 163 </div> 164 165 <script> 166 // ---------- Tabs ---------- 167 document.querySelectorAll('.tab-btn').forEach(btn => { 168 btn.addEventListener('click', () => { 169 document.querySelectorAll('.tab-btn').forEach(b => { 170 b.classList.remove('tab-active'); 171 b.classList.add('text-m3-onSurfaceVariant', 'dark:text-m3-onSurfaceVariantDark'); 172 }); 173 btn.classList.add('tab-active'); 174 btn.classList.remove('text-m3-onSurfaceVariant', 'dark:text-m3-onSurfaceVariantDark'); 175 176 document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hide')); 177 document.getElementById(btn.dataset.tab).classList.remove('hide'); 178 }); 179 }); 180 181 // ---------- Upload tab elements ---------- 182 const dropzone = document.getElementById('dropzone'); 183 const fileInput = document.getElementById('file-input'); 184 const customIdInput = document.getElementById('custom-id'); 185 const progressContainer = document.getElementById('progress-container'); 186 const progressBar = document.getElementById('progress-bar'); 187 const progressText = document.getElementById('progress-text'); 188 const processingContainer = document.getElementById('processing-container'); 189 const resultsList = document.getElementById('results-list'); 190 const errorContainer = document.getElementById('error-container'); 191 const errorText = document.getElementById('error-text'); 192 193 dropzone.addEventListener('click', () => fileInput.click()); 194 dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.style.borderColor = '#386A20'; }); 195 dropzone.addEventListener('dragleave', () => { dropzone.style.borderColor = 'transparent'; }); 196 dropzone.addEventListener('drop', (e) => { 197 e.preventDefault(); 198 dropzone.style.borderColor = 'transparent'; 199 if (e.dataTransfer.files.length) uploadFiles(Array.from(e.dataTransfer.files)); 200 }); 201 fileInput.addEventListener('change', () => { 202 if (fileInput.files.length) uploadFiles(Array.from(fileInput.files)); 203 }); 204 205 // Paste-to-upload 206 document.addEventListener('paste', (e) => { 207 if (document.getElementById('upload-tab').classList.contains('hide')) return; 208 const items = e.clipboardData && e.clipboardData.files; 209 if (items && items.length) uploadFiles(Array.from(items)); 210 }); 211 212 function resetUploadUI() { 213 progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); 214 processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); 215 errorContainer.classList.remove('show-flex'); errorContainer.classList.add('hide'); 216 progressBar.style.width = '0%'; 217 progressText.innerText = '0%'; 218 } 219 220 function showError(container, textEl, msg) { 221 textEl.innerText = msg; 222 container.classList.remove('hide'); 223 container.classList.add('show-flex'); 224 } 225 226 // Creates a single DOM element for a file result 227 function createResultCardDOM(item) { 228 const origin = window.location.origin; 229 const finalUrl = origin + item.link_path; 230 231 const card = document.createElement('div'); 232 card.className = "bg-m3-secondaryContainer dark:bg-m3-secondaryContainerDark p-4 rounded-[16px] border border-transparent"; 233 234 card.innerHTML = ` 235 <div class="flex items-center gap-2 mb-2"> 236 <span class="material-symbols-outlined text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark">check_circle</span> 237 <span class="font-bold text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark text-sm">${item.item_id}</span> 238 </div> 239 <div class="flex flex-col gap-3 bg-m3-background dark:bg-m3-backgroundDark p-3 rounded-[8px]"> 240 <div class="text-sm break-all select-all font-medium link-text">${finalUrl}</div> 241 <div class="flex gap-2 w-full"> 242 <button class="copy-btn flex-1 bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center gap-1.5"> 243 <span class="material-symbols-outlined copy-icon text-[18px]">content_copy</span><span class="copy-label">Copy</span> 244 </button> 245 <a href="${finalUrl}" target="_blank" class="open-btn flex-1 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center gap-1.5 no-underline"> 246 <span class="material-symbols-outlined text-[18px]">open_in_new</span><span>Open</span> 247 </a> 248 <button class="qr-btn bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center"> 249 <span class="material-symbols-outlined text-[18px]">qr_code_2</span> 250 </button> 251 </div> 252 </div>`; 253 254 card.querySelector('.copy-btn').addEventListener('click', function () { 255 navigator.clipboard.writeText(finalUrl).catch(() => {}); 256 const btn = this; 257 const icon = btn.querySelector('.copy-icon'); 258 const label = btn.querySelector('.copy-label'); 259 btn.classList.add('copied'); 260 icon.textContent = 'check'; 261 label.textContent = 'Copied'; 262 setTimeout(() => { 263 btn.classList.remove('copied'); 264 icon.textContent = 'content_copy'; 265 label.textContent = 'Copy'; 266 }, 1500); 267 }); 268 269 card.querySelector('.qr-btn').addEventListener('click', () => openQrModal(finalUrl, item.item_id)); 270 271 return card; 272 } 273 274 // Renders the structured batch layout or single file layout 275 function renderUploadSession(container, files, batchPath) { 276 const sessionDiv = document.createElement('div'); 277 sessionDiv.className = "flex flex-col mb-8 border-b-2 border-m3-surfaceContainerHigh dark:border-m3-surfaceContainerHighDark pb-6"; 278 279 if (batchPath) { 280 // Render Batch Link section 281 const batchHeader = document.createElement('h3'); 282 batchHeader.className = "text-base font-bold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mb-3 uppercase tracking-wide"; 283 batchHeader.innerText = "Batch Link:"; 284 sessionDiv.appendChild(batchHeader); 285 286 sessionDiv.appendChild(createResultCardDOM({ 287 item_id: "All Files (Batch)", 288 link_path: batchPath 289 })); 290 291 // Render Individual Links section 292 const indHeader = document.createElement('h3'); 293 indHeader.className = "text-base font-bold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mt-6 mb-3 uppercase tracking-wide"; 294 indHeader.innerText = "Individual File Links:"; 295 sessionDiv.appendChild(indHeader); 296 } else { 297 const singleHeader = document.createElement('h3'); 298 singleHeader.className = "text-base font-bold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mb-3 uppercase tracking-wide"; 299 singleHeader.innerText = "Uploaded File:"; 300 sessionDiv.appendChild(singleHeader); 301 } 302 303 // Render files 304 const filesContainer = document.createElement('div'); 305 filesContainer.className = "flex flex-col gap-3"; 306 files.forEach(f => filesContainer.appendChild(createResultCardDOM(f))); 307 sessionDiv.appendChild(filesContainer); 308 309 // Prepend the whole block to the top of results 310 container.prepend(sessionDiv); 311 } 312 313 function uploadFiles(files) { 314 resetUploadUI(); 315 316 const formData = new FormData(); 317 files.forEach(f => formData.append('files', f)); 318 319 const customId = customIdInput.value.trim(); 320 if (customId) { 321 if (files.length > 1) { 322 showError(errorContainer, errorText, "Custom ID can only be used with a single file."); 323 return; 324 } 325 formData.append('custom_id', customId); 326 } 327 328 progressContainer.classList.remove('hide'); progressContainer.classList.add('show'); 329 330 const xhr = new XMLHttpRequest(); 331 xhr.open('POST', '/api/upload', true); 332 333 xhr.upload.onprogress = function (e) { 334 if (e.lengthComputable) { 335 const pct = Math.round((e.loaded / e.total) * 100); 336 progressBar.style.width = pct + '%'; 337 progressText.innerText = pct + '%'; 338 339 if (pct === 100) { 340 progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); 341 processingContainer.classList.remove('hide'); processingContainer.classList.add('show'); 342 } 343 } 344 }; 345 346 xhr.onload = function () { 347 progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); 348 processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); 349 350 if (xhr.status >= 200 && xhr.status < 300) { 351 try { 352 const data = JSON.parse(xhr.responseText); 353 354 // Pass to our new structured rendering function 355 renderUploadSession(resultsList, data.files, data.batch_path); 356 357 fileInput.value = ''; customIdInput.value = ''; 358 } catch (err) { 359 showError(errorContainer, errorText, "Invalid response from server."); 360 } 361 } else { 362 try { 363 const errData = JSON.parse(xhr.responseText); 364 showError(errorContainer, errorText, errData.detail || 'Upload failed.'); 365 } catch (err) { 366 showError(errorContainer, errorText, "Upload failed."); 367 } 368 } 369 }; 370 371 xhr.onerror = function () { 372 progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); 373 processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); 374 showError(errorContainer, errorText, "System error: Could not connect to server."); 375 }; 376 377 xhr.send(formData); 378 } 379 380 // ---------- URL tab ---------- 381 const urlInput = document.getElementById('url-input'); 382 const urlCustomId = document.getElementById('url-custom-id'); 383 const urlUploadBtn = document.getElementById('url-upload-btn'); 384 const urlResultsList = document.getElementById('url-results-list'); 385 const urlErrorContainer = document.getElementById('url-error-container'); 386 const urlErrorText = document.getElementById('url-error-text'); 387 388 urlUploadBtn.addEventListener('click', async () => { 389 urlErrorContainer.classList.remove('show-flex'); urlErrorContainer.classList.add('hide'); 390 const url = urlInput.value.trim(); 391 if (!url) { showError(urlErrorContainer, urlErrorText, "Enter a URL first."); return; } 392 393 const formData = new FormData(); 394 formData.append('url', url); 395 396 const cid = urlCustomId.value.trim(); 397 if (cid) formData.append('custom_id', cid); 398 399 urlUploadBtn.disabled = true; 400 urlUploadBtn.innerText = 'Fetching & uploading...'; 401 402 try { 403 const res = await fetch('/api/upload-url', { method: 'POST', body: formData }); 404 const data = await res.json(); 405 if (!res.ok) throw new Error(data.detail || 'Upload failed.'); 406 407 renderUploadSession(urlResultsList, data.files, null); 408 409 urlInput.value = ''; urlCustomId.value = ''; 410 } catch (err) { 411 showError(urlErrorContainer, urlErrorText, err.message); 412 } finally { 413 urlUploadBtn.disabled = false; 414 urlUploadBtn.innerText = 'Fetch & Upload'; 415 } 416 }); 417 418 // ---------- QR modal ---------- 419 const qrModal = document.getElementById('qr-modal'); 420 const qrCodeBox = document.getElementById('qr-code-box'); 421 const qrDownloadBtn = document.getElementById('qr-download-btn'); 422 let qrFilename = 'qrcode'; 423 424 function openQrModal(url, name) { 425 qrCodeBox.innerHTML = ''; 426 qrFilename = name || 'qrcode'; 427 new QRCode(qrCodeBox, { text: url, width: 220, height: 220 }); 428 qrModal.classList.remove('hide'); qrModal.classList.add('show-flex'); 429 } 430 431 document.getElementById('qr-close-btn').addEventListener('click', () => { 432 qrModal.classList.remove('show-flex'); qrModal.classList.add('hide'); 433 }); 434 435 qrDownloadBtn.addEventListener('click', () => { 436 const canvas = qrCodeBox.querySelector('canvas'); 437 if (!canvas) return; 438 const a = document.createElement('a'); 439 a.href = canvas.toDataURL('image/png'); 440 a.download = qrFilename + '-qr.png'; 441 document.body.appendChild(a); 442 a.click(); 443 document.body.removeChild(a); 444 }); 445 </script> 446 </body> 447 </html>