index.html (34269B)
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>CDN | {{ page | capitalize }}</title> 7 <meta name="description" content="Personal CDN for browsing, uploading, and linking hosted assets."> 8 9 <link rel="icon" href="/favicon.ico" sizes="any"> 10 <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png"> 11 <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png"> 12 <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg"> 13 <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png"> 14 15 <link rel="manifest" href="/static/manifest.json"> 16 <meta name="theme-color" content="#050505"> 17 <meta name="color-scheme" content="dark"> 18 <meta name="apple-mobile-web-app-capable" content="yes"> 19 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 20 <meta name="apple-mobile-web-app-title" content="CDN"> 21 22 <meta property="og:title" content="CDN | {{ page | capitalize }}"> 23 <meta property="og:description" content="Personal CDN for browsing, uploading, and linking hosted assets."> 24 <meta property="og:image" content="/static/icons/og-logo.png"> 25 <meta property="og:type" content="website"> 26 <meta name="twitter:card" content="summary"> 27 28 <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script> 29 30 <style> 31 :root { 32 --bg: #050505; 33 --surface: #121212; 34 --text-main: #c9d1d9; 35 --text-dim: #8b949e; 36 --accent-pink: #ffb4ab; 37 --border: #21262d; 38 --danger: #f85149; 39 } 40 41 * { box-sizing: border-box; } 42 * { -webkit-tap-highlight-color: transparent; } 43 44 body { 45 font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; 46 background: var(--bg); 47 color: var(--text-main); 48 margin: 0; 49 padding: 3rem 1.5rem; 50 line-height: 1.6; 51 font-size: 1rem; 52 } 53 54 @media (max-width: 480px) { 55 body { 56 padding: 1.5rem 1rem; 57 font-size: 0.9rem; 58 } 59 } 60 61 .container { 62 max-width: 760px; 63 margin: 0 auto; 64 } 65 66 .top-accent { 67 width: 48px; 68 height: 2px; 69 background: linear-gradient(90deg, #5eead4 0%, #5eead4 33%, #fca5a5 33%, #fca5a5 66%, #ffffff 66%, #ffffff 100%); 70 margin-bottom: 2rem; 71 } 72 73 h1 { 74 font-size: 1.4rem; 75 text-transform: uppercase; 76 letter-spacing: 1px; 77 color: #ffffff; 78 font-weight: 600; 79 margin: 0 0 1.5rem 0; 80 } 81 82 p.intro { 83 color: var(--text-main); 84 margin-bottom: 1.5rem; 85 } 86 87 blockquote { 88 border-left: 2px solid var(--accent-pink); 89 margin: 0 0 1.5rem 0; 90 padding-left: 1rem; 91 color: var(--text-dim); 92 } 93 94 .upload-notice { 95 margin-bottom: 2.5rem; 96 color: var(--text-dim); 97 } 98 99 a { color: var(--accent-pink); text-decoration: none; } 100 a:hover { text-decoration: underline; } 101 102 .breadcrumb { 103 color: var(--accent-pink); 104 margin-bottom: 1rem; 105 } 106 .breadcrumb a { color: inherit; } 107 108 .list-container { 109 border: 1px solid var(--border); 110 border-radius: 4px; 111 background: var(--bg); 112 margin-bottom: 2rem; 113 } 114 115 .filter-input { 116 width: 100%; 117 padding: 0.85rem 1rem; 118 background: transparent; 119 border: none; 120 border-bottom: 1px solid var(--border); 121 color: var(--text-main); 122 font-family: inherit; 123 font-size: inherit; 124 } 125 .filter-input:focus { 126 outline: none; 127 background: var(--surface); 128 } 129 .filter-input::placeholder { color: var(--text-dim); } 130 131 .list-item { 132 display: flex; 133 align-items: center; 134 justify-content: space-between; 135 padding: 0.75rem 1rem; 136 border-bottom: 1px solid var(--border); 137 color: var(--text-main); 138 gap: 0.5rem; 139 } 140 .list-item:hover { 141 background: var(--surface); 142 } 143 .list-item:last-child { border-bottom: none; } 144 145 .item-name { 146 display: flex; 147 align-items: center; 148 gap: 0.75rem; 149 word-break: break-all; 150 min-width: 0; 151 flex: 1; 152 } 153 .item-name svg { 154 color: var(--text-dim); 155 flex-shrink: 0; 156 } 157 .item-name a { 158 overflow: hidden; 159 text-overflow: ellipsis; 160 white-space: nowrap; 161 } 162 163 .item-meta { 164 display: flex; 165 align-items: center; 166 gap: 1rem; 167 color: var(--text-dim); 168 font-size: 0.8rem; 169 white-space: nowrap; 170 flex-shrink: 0; 171 } 172 173 @media (max-width: 480px) { 174 .item-meta .size-label { 175 display: none; 176 } 177 } 178 179 .download-icon { 180 color: var(--text-dim); 181 transition: color 0.15s; 182 display: flex; 183 align-items: center; 184 padding: 0.25rem; 185 } 186 .download-icon:hover { 187 color: var(--accent-pink); 188 } 189 190 .btn { 191 display: inline-flex; 192 align-items: center; 193 gap: 0.5rem; 194 background: var(--surface); 195 color: var(--text-main); 196 padding: 0.4rem 0.8rem; 197 border: 1px solid var(--border); 198 border-radius: 4px; 199 cursor: pointer; 200 font-family: inherit; 201 font-size: 0.85rem; 202 transition: opacity 0.15s ease; 203 } 204 .btn:hover:not(:disabled) { background: #21262d; text-decoration: none; border-color: #30363d; } 205 .btn:disabled { opacity: 0.6; cursor: not-allowed; } 206 207 @media (max-width: 480px) { 208 .btn { 209 padding: 0.6rem 1rem; 210 font-size: 0.8rem; 211 } 212 } 213 214 #dropzone { 215 border: 1px dashed var(--border); 216 border-radius: 4px; 217 padding: 3rem 1.5rem; 218 text-align: center; 219 cursor: pointer; 220 color: var(--text-dim); 221 margin-bottom: 1.5rem; 222 transition: all 0.2s; 223 } 224 #dropzone:hover, #dropzone.dragover { 225 border-color: var(--accent-pink); 226 color: var(--accent-pink); 227 background: rgba(255, 180, 171, 0.05); 228 } 229 230 .file-row { 231 display: flex; justify-content: space-between; padding: 0.75rem; 232 border: 1px solid var(--border); border-radius: 4px; margin-bottom: 0.5rem; 233 align-items: center; 234 } 235 .file-row.done { border-color: #2ea043; } 236 .file-row.error { border-color: var(--danger); } 237 238 .turbo-progress-bar { 239 height: 3px; 240 background-color: var(--accent-pink); 241 } 242 243 .container { 244 transition: opacity 0.15s ease; 245 } 246 body.turbo-loading .container { 247 opacity: 0.5; 248 pointer-events: none; 249 } 250 251 .empty-state { 252 display: flex; 253 flex-direction: column; 254 align-items: center; 255 gap: 0.75rem; 256 padding: 3rem 1.5rem; 257 color: var(--text-dim); 258 } 259 .empty-state svg { opacity: 0.6; } 260 261 .breadcrumb { 262 display: flex; 263 align-items: center; 264 gap: 0.5rem; 265 flex-wrap: wrap; 266 } 267 .copy-path-btn { 268 background: none; 269 border: none; 270 color: var(--text-dim); 271 cursor: pointer; 272 display: inline-flex; 273 align-items: center; 274 padding: 0.15rem; 275 transition: color 0.15s; 276 } 277 .copy-path-btn:hover { color: var(--accent-pink); } 278 279 .listing-toolbar { 280 display: flex; 281 justify-content: space-between; 282 align-items: center; 283 flex-wrap: wrap; 284 gap: 0.5rem; 285 margin-bottom: 0.75rem; 286 } 287 288 @keyframes spin { 289 100% { transform: rotate(360deg); } 290 } 291 .spin-anim { 292 animation: spin 1s linear infinite; 293 } 294 295 .modal-overlay { 296 position: fixed; top: 0; left: 0; width: 100%; height: 100%; 297 background: rgba(0, 0, 0, 0.75); 298 display: flex; align-items: center; justify-content: center; 299 z-index: 1000; 300 backdrop-filter: blur(2px); 301 } 302 .modal { 303 background: var(--bg); 304 border: 1px solid var(--border); 305 border-radius: 6px; 306 padding: 1.5rem; 307 max-width: 420px; 308 width: 90%; 309 box-shadow: 0 10px 30px rgba(0,0,0,0.5); 310 } 311 .modal h3 { 312 margin-top: 0; margin-bottom: 1rem; 313 color: #fff; font-size: 1.2rem; 314 font-weight: 500; 315 } 316 .modal p { margin-bottom: 1.5rem; } 317 .modal-actions { 318 display: flex; justify-content: flex-end; gap: 0.75rem; 319 } 320 .btn-primary { 321 background: var(--accent-pink); 322 color: #000; 323 border-color: var(--accent-pink); 324 font-weight: 600; 325 } 326 .btn-primary:hover { 327 background: #ffc9c2; 328 border-color: #ffc9c2; 329 } 330 331 .upload-spinner { 332 display: inline-block; 333 width: 12px; height: 12px; 334 border: 2px solid var(--accent-pink); 335 border-top-color: transparent; 336 border-radius: 50%; 337 margin-right: 6px; 338 vertical-align: middle; 339 } 340 341 footer { 342 margin-top: 4rem; 343 padding-top: 1rem; 344 border-top: 1px solid var(--border); 345 color: var(--text-dim); 346 font-size: 0.8rem; 347 display: flex; 348 justify-content: space-between; 349 flex-wrap: wrap; 350 gap: 0.5rem; 351 } 352 footer a { color: var(--text-dim); } 353 footer a:hover { color: var(--text-main); } 354 </style> 355 </head> 356 <body> 357 <div class="container"> 358 {% if page == 'listing' and not path %} 359 <div class="top-accent"></div> 360 <h1>HEY, THIS IS <a href="https://amit.is-a.dev">MY</a> CDN.</h1> 361 <p class="intro">Assets for my other sites. Meant for me, but you're welcome to look around.</p> 362 <blockquote> 363 Please don't hotlink. I can change these URLs at any point without warning, so anything pointing here may break. 364 </blockquote> 365 <blockquote> 366 If you want to upload anything, go to <a href="/upload">here</a> and upload and you will get a link. 367 </blockquote> 368 {% endif %} 369 370 {% if page != 'listing' or path %} 371 <div class="top-accent" style="margin-bottom: 1.5rem;"></div> 372 {% endif %} 373 374 <div class="breadcrumb" id="breadcrumb"> 375 <a href="/">cdn</a> 376 {% if path %} 377 {% set parts = path.split('/') %} 378 {% set current = namespace(val='') %} 379 {% for part in parts %} 380 {% set current.val = current.val + part + '/' %} 381 / <a href="/{{ current.val[:-1] }}">{{ part }}</a> 382 {% endfor %} 383 {% endif %} 384 {% if page == 'upload' %} 385 / <a href="/upload">upload</a> 386 {% elif page == 'file' %} 387 / {{ filename }} 388 {% endif %} 389 {% if path or page == 'file' %} 390 <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}"> 391 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 392 </button> 393 {% endif %} 394 </div> 395 396 {% if page == 'listing' %} 397 {% if items %} 398 <div class="listing-toolbar"> 399 <div style="color: var(--text-dim); font-size: 0.9rem;"> 400 {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %} 401 {% set file_count = visible_items | rejectattr("is_dir") | list | length %} 402 {% set dir_count = visible_items | selectattr("is_dir") | list | length %} 403 404 {% if dir_count > 0 %} 405 {{ dir_count }} folder{{ 's' if dir_count != 1 else '' }} 406 {% endif %} 407 {% if dir_count > 0 and file_count > 0 %} & {% endif %} 408 {% if file_count > 0 %} 409 {{ file_count }} file{{ 's' if file_count != 1 else '' }} 410 {% endif %} 411 {% if dir_count == 0 and file_count == 0 %} 412 0 items 413 {% endif %} 414 </div> 415 <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip"> 416 <svg id="zip-spinner" class="spin-anim" style="display: none; width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 417 <line x1="12" y1="2" x2="12" y2="6"></line> 418 <line x1="12" y1="18" x2="12" y2="22"></line> 419 <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line> 420 <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line> 421 <line x1="2" y1="12" x2="6" y2="12"></line> 422 <line x1="18" y1="12" x2="22" y2="12"></line> 423 <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line> 424 <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line> 425 </svg> 426 <span id="zip-btn-text">Download all (.zip)</span> 427 </button> 428 </div> 429 {% endif %} 430 <div class="list-container"> 431 <input type="text" id="filter-input" class="filter-input" placeholder="Filter this folder... (press / to focus)" autocomplete="off" spellcheck="false"> 432 433 <div id="file-list"> 434 {% if path %} 435 <div class="list-item" data-name=".."> 436 <div class="item-name"> 437 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14l-4-4 4-4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/></svg> 438 {% set parent_path = '/' %} 439 {% set path_parts = path.split('/') %} 440 {% if path_parts|length > 1 %} 441 {% set parent_path = '/' + path_parts[:-1]|join('/') %} 442 {% endif %} 443 <a href="{{ parent_path }}">..</a> 444 </div> 445 </div> 446 {% endif %} 447 448 {% for item in items %} 449 {% if item.name != '.gitattributes' %} 450 <div class="list-item" data-name="{{ item.name }}"> 451 <div class="item-name"> 452 {% if item.is_dir %} 453 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> 454 <a href="/{{ item.path }}">{{ item.name }}/</a> 455 {% else %} 456 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> 457 <a href="{{ raw_base_url }}/{{ item.path }}" data-turbo="false">{{ item.name }}</a> 458 {% endif %} 459 </div> 460 <div class="item-meta"> 461 {% if not item.is_dir %} 462 <span>{{ item.size_str }}</span> 463 <a href="/api/download/{{ item.path }}" class="download-icon" title="Download" data-turbo="false"> 464 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg> 465 </a> 466 {% endif %} 467 </div> 468 </div> 469 {% endif %} 470 {% endfor %} 471 472 {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %} 473 {% if not visible_items %} 474 <div class="empty-state"> 475 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> 476 <span>Empty directory</span> 477 </div> 478 {% endif %} 479 </div> 480 </div> 481 482 {% elif page == 'file' %} 483 <div class="list-container" style="padding: 3rem 1.5rem; text-align: center;"> 484 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" style="margin-bottom: 1rem;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> 485 <h2 style="margin: 0 0 0.5rem 0; font-weight: normal; color: var(--accent-pink);">{{ filename }}</h2> 486 <p style="color: var(--text-dim); margin-bottom: 2rem; font-size: 0.85rem; word-break: break-all;">{{ path }}</p> 487 488 <div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; padding: 0 0.5rem;"> 489 <a href="/api/download/{{ path }}" class="btn" data-turbo="false">Download</a> 490 <a href="{{ raw_base_url }}/{{ path }}" class="btn" data-turbo="false">Open Raw</a> 491 <button onclick="copyToClipboard(window.location.origin + '/' + '{{ path }}', this)" class="btn">Copy Link</button> 492 <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}', this)" class="btn">Copy Raw Link</button> 493 </div> 494 </div> 495 496 {% elif page == '404' %} 497 <div class="list-container" style="padding: 4rem 1.5rem; text-align: center;"> 498 <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="1.5" style="margin-bottom: 1rem;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> 499 <h2 style="margin: 0 0 0.5rem 0; font-weight: normal; color: var(--danger);">404 - Not Found</h2> 500 <p style="color: var(--text-dim); margin-bottom: 2rem;">The file or directory you're looking for doesn't exist.</p> 501 <a href="/" class="btn">Go to Root</a> 502 </div> 503 504 {% elif page == 'upload' %} 505 <blockquote> 506 If you do not remember the link, go to the <a href="/uploads">uploads folder</a> to find your file. 507 </blockquote> 508 509 <div id="dropzone"> 510 <div>drag & drop files here</div> 511 <div style="font-size: 0.8rem; margin-top: 0.5rem;">or click to select</div> 512 <input type="file" id="file-input" multiple style="display:none;"> 513 </div> 514 515 <div id="error-box" style="display: none; color: var(--danger); margin-bottom: 1rem; padding: 0.75rem; border: 1px solid var(--danger); border-radius: 4px;"></div> 516 <div id="results"></div> 517 518 <script> 519 const dropzone = document.getElementById('dropzone'); 520 const fileInput = document.getElementById('file-input'); 521 const results = document.getElementById('results'); 522 const errorBox = document.getElementById('error-box'); 523 524 const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024; 525 526 dropzone.addEventListener('click', () => fileInput.click()); 527 dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); 528 dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); 529 dropzone.addEventListener('drop', (e) => { 530 e.preventDefault(); dropzone.classList.remove('dragover'); 531 if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); 532 }); 533 fileInput.addEventListener('change', () => { 534 if (fileInput.files.length) handleFiles(fileInput.files); 535 }); 536 537 function showError(msg) { 538 errorBox.textContent = msg; 539 errorBox.style.display = 'block'; 540 } 541 542 function formatBytes(bytes) { 543 if (bytes < 1024) return bytes + ' B'; 544 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; 545 return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; 546 } 547 548 function handleFiles(fileList) { 549 errorBox.style.display = 'none'; 550 551 const allFiles = Array.from(fileList); 552 const files = []; 553 const rejected = []; 554 555 allFiles.forEach(f => { 556 if (f.size > MAX_FILE_SIZE_BYTES) { 557 rejected.push(f.name); 558 } else { 559 files.push(f); 560 } 561 }); 562 563 if (rejected.length) { 564 showError( 565 `Skipped ${rejected.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejected.join(', ')}` 566 ); 567 } 568 569 if (!files.length) return; 570 571 const rows = {}; 572 files.forEach(f => { 573 const row = document.createElement('div'); 574 row.className = 'file-row'; 575 row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${f.name}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;">uploading...</div>`; 576 results.prepend(row); 577 rows[f.name] = row; 578 }); 579 580 const formData = new FormData(); 581 files.forEach(f => formData.append('files', f)); 582 583 const xhr = new XMLHttpRequest(); 584 xhr.open('POST', '/api/upload', true); 585 586 xhr.upload.onprogress = (e) => { 587 if (!e.lengthComputable) return; 588 const pct = Math.round((e.loaded / e.total) * 100); 589 files.forEach(f => { 590 const statusNode = rows[f.name].querySelector('.status'); 591 if (pct === 100) { 592 statusNode.innerHTML = `<span class="upload-spinner spin-anim"></span><span style="color: var(--accent-pink);">saving to HF...</span>`; 593 } else { 594 statusNode.textContent = pct + '%'; 595 } 596 }); 597 }; 598 599 xhr.onload = () => { 600 if (xhr.status >= 200 && xhr.status < 300) { 601 try { 602 const data = JSON.parse(xhr.responseText); 603 data.files.forEach(item => { 604 const row = rows[item.filename]; 605 if (!row) return; 606 row.classList.add('done'); 607 const rawPath = item.raw_url; 608 row.innerHTML = ` 609 <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: #2ea043;">${item.filename}</div> 610 <div style="white-space:nowrap; display:flex; gap:0.75rem;"> 611 <a href="${rawPath}" target="_blank">raw</a> 612 </div> 613 `; 614 }); 615 } catch (err) { showError('invalid response from server.'); } 616 } else { 617 let msg = 'upload failed.'; 618 try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) {} 619 showError(msg); 620 files.forEach(f => { 621 rows[f.name].classList.add('error'); 622 rows[f.name].querySelector('.status').textContent = 'failed'; 623 rows[f.name].querySelector('.status').style.color = 'var(--danger)'; 624 }); 625 } 626 fileInput.value = ''; 627 }; 628 xhr.onerror = () => { showError('connection error.'); }; 629 xhr.send(formData); 630 } 631 </script> 632 {% endif %} 633 634 <div id="zip-modal-overlay" class="modal-overlay" style="display: none;"> 635 <div class="modal"> 636 <h3>Confirm Download</h3> 637 <p id="zip-modal-text" style="color: var(--text-main); font-size: 0.95rem;"></p> 638 <div class="modal-actions"> 639 <button type="button" class="btn" id="zip-modal-cancel">Cancel</button> 640 <a href="#" class="btn btn-primary" id="zip-modal-confirm" data-turbo="false">Download (.zip)</a> 641 </div> 642 </div> 643 </div> 644 645 <div id="error-modal-overlay" class="modal-overlay" style="display: none;"> 646 <div class="modal"> 647 <h3 style="color: var(--danger); margin-top: 0; margin-bottom: 1rem; font-size: 1.2rem; font-weight: 500;">Notice</h3> 648 <p id="error-modal-text" style="color: var(--text-main); font-size: 0.95rem; margin-bottom: 1.5rem;"></p> 649 <div class="modal-actions"> 650 <button type="button" class="btn" id="error-modal-close">Close</button> 651 </div> 652 </div> 653 </div> 654 655 <footer> 656 <div> 657 cdn · © 2026 · assets may move without notice 658 </div> 659 {% if repo_file_count is not none %} 660 <div title="Across the whole repo"> 661 {{ repo_file_count }} file{{ '' if repo_file_count == 1 else 's' }} · {{ repo_size_str }} total 662 </div> 663 {% endif %} 664 </footer> 665 </div> 666 667 <script> 668 function copyToClipboard(text, btn) { 669 navigator.clipboard.writeText(text).then(() => { 670 if (!btn) return; 671 const original = btn.textContent; 672 btn.textContent = 'Copied!'; 673 btn.disabled = true; 674 setTimeout(() => { 675 btn.textContent = original; 676 btn.disabled = false; 677 }, 1500); 678 }).catch(err => { 679 console.error('failed to copy: ', err); 680 }); 681 } 682 683 function copyBreadcrumbPath(btn) { 684 const path = btn.getAttribute('data-path') || ''; 685 navigator.clipboard.writeText(path).then(() => { 686 const original = btn.innerHTML; 687 btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 688 setTimeout(() => { btn.innerHTML = original; }, 1200); 689 }).catch(err => { 690 console.error('failed to copy: ', err); 691 }); 692 } 693 694 if (!window.__CDN_App_Initialized) { 695 window.__CDN_App_Initialized = true; 696 697 document.addEventListener('input', (e) => { 698 if (e.target.id === 'filter-input') { 699 const term = e.target.value.toLowerCase(); 700 const fileList = document.querySelectorAll('#file-list .list-item[data-name]'); 701 fileList.forEach(item => { 702 const name = item.getAttribute('data-name').toLowerCase(); 703 if (name === '..') return; 704 item.style.display = name.includes(term) ? 'flex' : 'none'; 705 }); 706 } 707 }); 708 709 document.addEventListener('click', async (e) => { 710 const zipBtn = e.target.closest('#download-zip-btn'); 711 if (zipBtn) { 712 e.preventDefault(); 713 if (zipBtn.disabled) return; 714 715 const path = zipBtn.getAttribute('data-path') || ''; 716 const zipSpinner = document.getElementById('zip-spinner'); 717 const zipBtnText = document.getElementById('zip-btn-text'); 718 const zipModalOverlay = document.getElementById('zip-modal-overlay'); 719 720 zipBtn.disabled = true; 721 if (zipSpinner) zipSpinner.style.display = 'block'; 722 if (zipBtnText) zipBtnText.textContent = 'Calculating...'; 723 724 try { 725 const res = await fetch('/api/zip-stats/' + path); 726 const data = await res.json(); 727 728 if (!res.ok) { 729 throw new Error(data.detail || 'Failed to get directory stats'); 730 } 731 732 const folderName = path ? path.split('/').pop() : 'root'; 733 document.getElementById('zip-modal-text').innerHTML = 734 `Are you sure you want to download <strong>${folderName}</strong>?<br><br>` + 735 `This will package <strong>${data.file_count} file(s)</strong> totaling <strong>${data.size_str}</strong> into a .zip.`; 736 737 document.getElementById('zip-modal-confirm').href = '/api/download-zip/' + path; 738 zipModalOverlay.style.display = 'flex'; 739 } catch (err) { 740 const errorModalOverlay = document.getElementById('error-modal-overlay'); 741 if (errorModalOverlay) { 742 document.getElementById('error-modal-text').textContent = err.message; 743 errorModalOverlay.style.display = 'flex'; 744 } else { 745 alert(err.message); 746 } 747 } finally { 748 zipBtn.disabled = false; 749 if (zipSpinner) zipSpinner.style.display = 'none'; 750 if (zipBtnText) zipBtnText.textContent = 'Download all (.zip)'; 751 } 752 } 753 754 if (e.target.closest('#zip-modal-cancel') || e.target.closest('#zip-modal-confirm')) { 755 const zipOverlay = document.getElementById('zip-modal-overlay'); 756 if (zipOverlay) zipOverlay.style.display = 'none'; 757 } 758 759 if (e.target.closest('#error-modal-close')) { 760 const errOverlay = document.getElementById('error-modal-overlay'); 761 if (errOverlay) errOverlay.style.display = 'none'; 762 } 763 }); 764 765 if ('serviceWorker' in navigator) { 766 window.addEventListener('load', () => { 767 navigator.serviceWorker.register('/static/sw.js').catch(err => { 768 console.error('service worker registration failed:', err); 769 }); 770 }); 771 } 772 773 document.addEventListener('turbo:load', () => { 774 if (window.Turbo && window.Turbo.setProgressBarDelay) { 775 window.Turbo.setProgressBarDelay(0); 776 } 777 }, { once: true }); 778 document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading')); 779 document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading')); 780 document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading')); 781 document.addEventListener('turbo:render', () => document.body.classList.remove('turbo-loading')); 782 document.addEventListener('turbo:load', () => document.body.classList.remove('turbo-loading')); 783 784 document.addEventListener('keydown', (e) => { 785 if (e.key !== '/') return; 786 const tag = (document.activeElement && document.activeElement.tagName) || ''; 787 if (tag === 'INPUT' || tag === 'TEXTAREA') return; 788 const filterInput = document.getElementById('filter-input'); 789 if (filterInput) { 790 e.preventDefault(); 791 filterInput.focus(); 792 } 793 }); 794 } 795 </script> 796 </body> 797 </html>