madebydanny.uk written in html, css, and a lot of JavaScript I don't understand madeydanny.uk
html css javascript

updated CDN

+1316 -659
-658
cdn.html
··· 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>MBD CDN - madebydanny.uk</title> 7 - <script src="https://kit.fontawesome.com/0ca27f8db1.js" crossorigin="anonymous"></script> 8 - <link rel="icon" href="https://public-cdn.madebydanny.uk/user-content/2026-01-30/33913bec-bc2f-4e6c-a474-2ef8f8c00197"> 9 - <meta name="description" content="The MBD CDN is a network of servers located around the world powered by the Cloudflare global network."> 10 - <meta property="og:title" content="MBD CDN - madebydanny.uk"> 11 - <meta property="og:description" content="The MBD CDN is a network of servers located around the world powered by the Cloudflare global network."> 12 - <meta property="og:image" content="https://imrs.madebydanny.uk?url=https://public-cdn.madebydanny.uk/user-content/2026-01-30/cb09a559-ae35-4617-971c-9230521f7a9c.png"> 13 - <meta property="og:type" content="website"> 14 - 15 - <style> 16 - /* --- THEME VARIABLES --- */ 17 - :root { 18 - --card-bg: #4a2b32; 19 - --post-bg: #1e1e1e; 20 - --text-color: #ffffff; 21 - --subtext-color: #dcbaba; 22 - --link-color: #ffcccc; 23 - --border: rgba(255,255,255,0.1); 24 - --success-green: #34c759; 25 - --error-red: #ff6b6b; 26 - --stat-card-bg: #2a1a21; 27 - } 28 - 29 - * { 30 - box-sizing: border-box; 31 - } 32 - 33 - body { 34 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 35 - background-color: #121212; 36 - color: var(--text-color); 37 - padding: 20px; 38 - line-height: 1.5; 39 - margin: auto; 40 - text-align: center; 41 - max-width: 900px; 42 - } 43 - 44 - h1 { 45 - font-weight: 700; 46 - letter-spacing: -1px; 47 - margin-bottom: 8px; 48 - font-size: 2.2rem; 49 - } 50 - 51 - p { 52 - color: var(--subtext-color); 53 - margin-top: 0; 54 - } 55 - 56 - a { 57 - color: var(--link-color); 58 - text-decoration: none; 59 - transition: color 0.2s ease; 60 - } 61 - 62 - a:hover { 63 - text-decoration: underline; 64 - color: #ffd9d9; 65 - } 66 - 67 - /* --- STATS GRID --- */ 68 - .stats-grid { 69 - display: grid; 70 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 71 - gap: 15px; 72 - margin: 30px 0; 73 - } 74 - 75 - .stat-card { 76 - background: linear-gradient(135deg, var(--stat-card-bg) 0%, var(--card-bg) 100%); 77 - border: 1px solid var(--border); 78 - border-radius: 12px; 79 - padding: 20px; 80 - text-align: center; 81 - transition: transform 0.2s ease, box-shadow 0.2s ease; 82 - position: relative; 83 - overflow: hidden; 84 - } 85 - 86 - .stat-card::before { 87 - content: ''; 88 - position: absolute; 89 - top: 0; 90 - left: 0; 91 - right: 0; 92 - height: 3px; 93 - background: linear-gradient(90deg, var(--link-color), rgba(255,204,204,0.3)); 94 - opacity: 0; 95 - transition: opacity 0.3s ease; 96 - } 97 - 98 - .stat-card:hover { 99 - transform: translateY(-5px); 100 - box-shadow: 0 8px 20px rgba(0,0,0,0.4); 101 - } 102 - 103 - .stat-card:hover::before { 104 - opacity: 1; 105 - } 106 - 107 - .stat-icon { 108 - font-size: 2rem; 109 - margin-bottom: 10px; 110 - opacity: 0.8; 111 - } 112 - 113 - .stat-value { 114 - font-size: 2rem; 115 - font-weight: 700; 116 - color: var(--text-color); 117 - margin: 5px 0; 118 - letter-spacing: -1px; 119 - } 120 - 121 - .stat-label { 122 - font-size: 0.85rem; 123 - color: var(--subtext-color); 124 - text-transform: uppercase; 125 - letter-spacing: 0.5px; 126 - font-weight: 600; 127 - } 128 - 129 - .stat-loading { 130 - opacity: 0.5; 131 - } 132 - 133 - /* --- UPLOADER STYLING --- */ 134 - .upload-card { 135 - background-color: var(--card-bg); 136 - border: 1px solid var(--border); 137 - border-radius: 16px; 138 - padding: 35px; 139 - margin: 40px auto; 140 - box-shadow: 0 6px 12px rgba(0,0,0,0.4); 141 - } 142 - 143 - .file-input-label { 144 - display: block; 145 - border: 2px dashed var(--border); 146 - border-radius: 12px; 147 - padding: 50px 20px; 148 - cursor: pointer; 149 - transition: all 0.3s ease; 150 - color: var(--subtext-color); 151 - background: rgba(0,0,0,0.2); 152 - } 153 - 154 - .file-input-label:hover { 155 - border-color: var(--link-color); 156 - background: rgba(255,255,255,0.03); 157 - transform: scale(1.01); 158 - } 159 - 160 - .file-input-label.drag-over { 161 - border-color: var(--success-green); 162 - background: rgba(52, 199, 89, 0.05); 163 - transform: scale(1.02); 164 - } 165 - 166 - .file-input-label i { 167 - font-size: 2.5rem; 168 - margin-bottom: 15px; 169 - display: block; 170 - opacity: 0.7; 171 - } 172 - 173 - #file-input { 174 - display: none; 175 - } 176 - 177 - #file-name { 178 - display: block; 179 - margin-top: 10px; 180 - font-size: 1rem; 181 - font-weight: 500; 182 - } 183 - 184 - .file-info { 185 - margin-top: 15px; 186 - padding: 12px; 187 - background: var(--post-bg); 188 - border-radius: 8px; 189 - border: 1px solid var(--border); 190 - display: none; 191 - } 192 - 193 - .file-info.show { 194 - display: block; 195 - } 196 - 197 - .file-detail { 198 - font-size: 0.85rem; 199 - color: var(--subtext-color); 200 - margin: 5px 0; 201 - } 202 - 203 - button { 204 - background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%); 205 - color: #000000; 206 - border: none; 207 - padding: 14px 28px; 208 - border-radius: 999px; 209 - font-weight: 600; 210 - margin-top: 20px; 211 - cursor: pointer; 212 - width: 100%; 213 - transition: all 0.2s ease; 214 - font-size: 1rem; 215 - box-shadow: 0 2px 8px rgba(255,255,255,0.1); 216 - } 217 - 218 - button:hover { 219 - transform: translateY(-2px); 220 - box-shadow: 0 4px 12px rgba(255,255,255,0.2); 221 - } 222 - 223 - button:active { 224 - transform: scale(0.98); 225 - } 226 - 227 - button:disabled { 228 - opacity: 0.5; 229 - cursor: not-allowed; 230 - transform: none; 231 - } 232 - 233 - /* --- PROGRESS BAR --- */ 234 - .progress-container { 235 - margin-top: 15px; 236 - display: none; 237 - } 238 - 239 - .progress-container.show { 240 - display: block; 241 - } 242 - 243 - .progress-bar { 244 - width: 100%; 245 - height: 8px; 246 - background: var(--post-bg); 247 - border-radius: 999px; 248 - overflow: hidden; 249 - border: 1px solid var(--border); 250 - } 251 - 252 - .progress-fill { 253 - height: 100%; 254 - background: linear-gradient(90deg, var(--link-color), var(--success-green)); 255 - width: 0%; 256 - transition: width 0.3s ease; 257 - border-radius: 999px; 258 - } 259 - 260 - /* --- RESULT AREA --- */ 261 - #result { 262 - margin-top: 20px; 263 - display: none; 264 - text-align: left; 265 - background: var(--post-bg); 266 - padding: 20px; 267 - border-radius: 12px; 268 - border: 1px solid var(--success-green); 269 - animation: slideIn 0.3s ease; 270 - } 271 - 272 - @keyframes slideIn { 273 - from { 274 - opacity: 0; 275 - transform: translateY(-10px); 276 - } 277 - to { 278 - opacity: 1; 279 - transform: translateY(0); 280 - } 281 - } 282 - 283 - #result.show { 284 - display: block; 285 - } 286 - 287 - #result small { 288 - color: var(--subtext-color); 289 - font-weight: 600; 290 - text-transform: uppercase; 291 - font-size: 0.75rem; 292 - letter-spacing: 0.5px; 293 - } 294 - 295 - #url-text { 296 - margin: 10px 0; 297 - font-family: 'Monaco', 'Courier New', monospace; 298 - font-size: 0.9rem; 299 - padding: 12px; 300 - background: rgba(0,0,0,0.3); 301 - border-radius: 6px; 302 - border: 1px solid var(--border); 303 - word-break: break-all; 304 - } 305 - 306 - .copy-btn { 307 - background: rgba(255,255,255,0.06); 308 - color: var(--link-color); 309 - border: 1px solid var(--border); 310 - font-size: 0.9rem; 311 - padding: 10px 16px; 312 - width: auto; 313 - margin-top: 10px; 314 - display: inline-flex; 315 - align-items: center; 316 - gap: 8px; 317 - } 318 - 319 - .copy-btn:hover { 320 - background: rgba(255,255,255,0.1); 321 - border-color: var(--link-color); 322 - } 323 - 324 - hr { 325 - border: none; 326 - border-top: 1px solid var(--border); 327 - margin: 40px 0; 328 - } 329 - 330 - .status-msg { 331 - margin-top: 15px; 332 - font-size: 0.95rem; 333 - color: var(--link-color); 334 - font-weight: 500; 335 - } 336 - 337 - /* --- SOCIAL BUTTONS --- */ 338 - .social-row { 339 - display: flex; 340 - gap: 12px; 341 - flex-wrap: wrap; 342 - margin: 15px 0 20px 0; 343 - justify-content: center; 344 - } 345 - 346 - .social-btn { 347 - display: inline-flex; 348 - align-items: center; 349 - justify-content: center; 350 - gap: 8px; 351 - padding: 10px 16px; 352 - border-radius: 999px; 353 - background: rgba(255,255,255,0.04); 354 - color: var(--text-color); 355 - text-decoration: none; 356 - border: 1px solid var(--border); 357 - font-size: 0.95rem; 358 - flex: 0 0 auto; 359 - min-width: 44px; 360 - transition: all 0.2s ease; 361 - } 362 - 363 - .social-btn:hover { 364 - background: rgba(255,255,255,0.08); 365 - transform: translateY(-2px); 366 - box-shadow: 0 4px 12px rgba(0,0,0,0.3); 367 - text-decoration: none; 368 - } 369 - 370 - .social-btn i { 371 - width: 1.1em; 372 - text-align: center; 373 - font-size: 1.05em; 374 - } 375 - 376 - .social-btn .label { 377 - display: inline-block; 378 - white-space: nowrap; 379 - font-weight: 600; 380 - font-size: 0.95rem; 381 - } 382 - 383 - /* --- FOOTER --- */ 384 - #site-footer { 385 - font-size: 0.85rem; 386 - color: var(--subtext-color); 387 - padding: 20px 0; 388 - } 389 - 390 - /* --- RESPONSIVE --- */ 391 - @media (max-width: 600px) { 392 - .stats-grid { 393 - grid-template-columns: repeat(2, 1fr); 394 - } 395 - 396 - h1 { 397 - font-size: 1.8rem; 398 - } 399 - 400 - .stat-value { 401 - font-size: 1.5rem; 402 - } 403 - } 404 - </style> 405 - </head> 406 - <body> 407 - 408 - <h1><i class="fa-solid fa-database" style="color: #ffffff;"></i> MBD CDN</h1> 409 - <p> 410 - The MBD CDN is a network of servers located around the world powered by the Cloudflare global network. 411 - <br> 412 - It's designed to deliver media <i>(images, videos, live broadcasts, etc)</i> at extremely fast speeds with 100% uptime. 413 - </p> 414 - 415 - <!-- Stats Cards --> 416 - <div class="stats-grid"> 417 - <div class="stat-card"> 418 - <div class="stat-icon"><i class="fa-regular fa-image"></i></div> 419 - <div class="stat-value stat-loading" id="stat-images">--</div> 420 - <div class="stat-label">Images</div> 421 - </div> 422 - <div class="stat-card"> 423 - <div class="stat-icon"><i class="fa-solid fa-video"></i></div> 424 - <div class="stat-value stat-loading" id="stat-videos">--</div> 425 - <div class="stat-label">Videos</div> 426 - </div> 427 - <div class="stat-card"> 428 - <div class="stat-icon"><i class="fa-solid fa-photo-film"></i></div> 429 - <div class="stat-value stat-loading" id="stat-gifs">--</div> 430 - <div class="stat-label">GIFs</div> 431 - </div> 432 - <div class="stat-card"> 433 - <div class="stat-icon"><i class="fa-solid fa-database"></i></div> 434 - <div class="stat-value stat-loading" id="stat-storage">--</div> 435 - <div class="stat-label">Storage Used</div> 436 - </div> 437 - </div> 438 - 439 - <!-- Upload Card --> 440 - <div class="upload-card"> 441 - <label for="file-input" class="file-input-label" id="drop-zone"> 442 - <span id="file-name">Click to select or drag file here</span> 443 - </label> 444 - <input type="file" id="file-input"> 445 - 446 - <div class="file-info" id="file-info"> 447 - <div class="file-detail"><strong>File:</strong> <span id="detail-name"></span></div> 448 - <div class="file-detail"><strong>Size:</strong> <span id="detail-size"></span></div> 449 - <div class="file-detail"><strong>Type:</strong> <span id="detail-type"></span></div> 450 - </div> 451 - 452 - <div class="progress-container" id="progress-container"> 453 - <div class="progress-bar"> 454 - <div class="progress-fill" id="progress-fill"></div> 455 - </div> 456 - </div> 457 - 458 - <button id="upload-btn"> 459 - <i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN 460 - </button> 461 - <div id="status" class="status-msg"></div> 462 - 463 - <div id="result"> 464 - <small>✅ Public URL:</small> 465 - <div id="url-text"></div> 466 - <button class="copy-btn" onclick="copyUrl()"> 467 - <i class="fa-solid fa-copy"></i> Copy URL 468 - </button> 469 - </div> 470 - </div> 471 - 472 - <hr> 473 - <p><b>Social Links:</b></p> 474 - <div id="social-links"></div> 475 - <hr> 476 - <div id="site-footer"></div> 477 - <script src="/js/social-links.js"></script> 478 - 479 - <script> 480 - const fileInput = document.getElementById('file-input'); 481 - const fileNameDisp = document.getElementById('file-name'); 482 - const uploadBtn = document.getElementById('upload-btn'); 483 - const status = document.getElementById('status'); 484 - const result = document.getElementById('result'); 485 - const urlText = document.getElementById('url-text'); 486 - const dropZone = document.getElementById('drop-zone'); 487 - const fileInfo = document.getElementById('file-info'); 488 - const progressContainer = document.getElementById('progress-container'); 489 - const progressFill = document.getElementById('progress-fill'); 490 - 491 - // Format bytes to human readable 492 - function formatBytes(bytes) { 493 - if (bytes === 0) return '0 Bytes'; 494 - const k = 1024; 495 - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; 496 - const i = Math.floor(Math.log(bytes) / Math.log(k)); 497 - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; 498 - } 499 - 500 - // Format large numbers 501 - function formatNumber(num) { 502 - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 503 - } 504 - 505 - // Load statistics 506 - async function loadStats() { 507 - try { 508 - const response = await fetch('https://cdn.madebydanny.uk/stats'); 509 - const data = await response.json(); 510 - 511 - if (data.success) { 512 - document.getElementById('stat-images').innerText = formatNumber(data.stats.images); 513 - document.getElementById('stat-videos').innerText = formatNumber(data.stats.videos); 514 - document.getElementById('stat-gifs').innerText = formatNumber(data.stats.gifs); 515 - document.getElementById('stat-storage').innerText = formatBytes(data.stats.totalSize); 516 - 517 - // Remove loading state 518 - document.querySelectorAll('.stat-value').forEach(el => { 519 - el.classList.remove('stat-loading'); 520 - }); 521 - } 522 - } catch (err) { 523 - console.error('Failed to load stats:', err); 524 - document.querySelectorAll('.stat-value').forEach(el => { 525 - el.innerText = '—'; 526 - el.classList.remove('stat-loading'); 527 - }); 528 - } 529 - } 530 - 531 - // Load stats on page load 532 - loadStats(); 533 - 534 - // Update UI when file selected 535 - fileInput.addEventListener('change', () => { 536 - if (fileInput.files.length > 0) { 537 - const file = fileInput.files[0]; 538 - fileNameDisp.innerText = file.name; 539 - 540 - // Show file info 541 - document.getElementById('detail-name').innerText = file.name; 542 - document.getElementById('detail-size').innerText = formatBytes(file.size); 543 - document.getElementById('detail-type').innerText = file.type || 'Unknown'; 544 - fileInfo.classList.add('show'); 545 - } 546 - }); 547 - 548 - // Drag and drop support 549 - dropZone.addEventListener('dragover', (e) => { 550 - e.preventDefault(); 551 - dropZone.classList.add('drag-over'); 552 - }); 553 - 554 - dropZone.addEventListener('dragleave', (e) => { 555 - e.preventDefault(); 556 - dropZone.classList.remove('drag-over'); 557 - }); 558 - 559 - dropZone.addEventListener('drop', (e) => { 560 - e.preventDefault(); 561 - dropZone.classList.remove('drag-over'); 562 - 563 - if (e.dataTransfer.files.length > 0) { 564 - fileInput.files = e.dataTransfer.files; 565 - const file = e.dataTransfer.files[0]; 566 - fileNameDisp.innerText = file.name; 567 - 568 - // Show file info 569 - document.getElementById('detail-name').innerText = file.name; 570 - document.getElementById('detail-size').innerText = formatBytes(file.size); 571 - document.getElementById('detail-type').innerText = file.type || 'Unknown'; 572 - fileInfo.classList.add('show'); 573 - } 574 - }); 575 - 576 - uploadBtn.addEventListener('click', async () => { 577 - if (fileInput.files.length === 0) { 578 - status.innerText = "⚠️ Please select a file first."; 579 - status.style.color = 'var(--error-red)'; 580 - return; 581 - } 582 - 583 - const file = fileInput.files[0]; 584 - uploadBtn.disabled = true; 585 - uploadBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading...'; 586 - status.innerText = ""; 587 - result.classList.remove('show'); 588 - progressContainer.classList.add('show'); 589 - progressFill.style.width = '0%'; 590 - 591 - // Simulate progress 592 - let progress = 0; 593 - const progressInterval = setInterval(() => { 594 - progress += Math.random() * 15; 595 - if (progress > 90) progress = 90; 596 - progressFill.style.width = progress + '%'; 597 - }, 200); 598 - 599 - try { 600 - const response = await fetch('https://cdn.madebydanny.uk', { 601 - method: 'POST', 602 - body: file, 603 - headers: { 604 - 'Content-Type': file.type || 'application/octet-stream' 605 - } 606 - }); 607 - 608 - clearInterval(progressInterval); 609 - progressFill.style.width = '100%'; 610 - 611 - const data = await response.json(); 612 - 613 - if (data.success) { 614 - result.classList.add('show'); 615 - urlText.innerText = data.url; 616 - status.style.color = 'var(--success-green)'; 617 - status.innerText = "✅ Upload successful!"; 618 - 619 - // Reload stats 620 - setTimeout(() => loadStats(), 500); 621 - 622 - // Reset form after 3 seconds 623 - setTimeout(() => { 624 - fileInput.value = ''; 625 - fileNameDisp.innerText = 'Click to select or drag file here'; 626 - fileInfo.classList.remove('show'); 627 - progressContainer.classList.remove('show'); 628 - }, 3000); 629 - } else { 630 - throw new Error(data.error || "Upload failed"); 631 - } 632 - } catch (err) { 633 - clearInterval(progressInterval); 634 - progressContainer.classList.remove('show'); 635 - status.style.color = 'var(--error-red)'; 636 - status.innerText = "❌ Error: " + err.message; 637 - } finally { 638 - uploadBtn.disabled = false; 639 - uploadBtn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN'; 640 - } 641 - }); 642 - 643 - function copyUrl() { 644 - navigator.clipboard.writeText(urlText.innerText); 645 - const copyBtn = document.querySelector('.copy-btn'); 646 - const originalHTML = copyBtn.innerHTML; 647 - copyBtn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!'; 648 - copyBtn.style.background = 'var(--success-green)'; 649 - copyBtn.style.color = '#000'; 650 - setTimeout(() => { 651 - copyBtn.innerHTML = originalHTML; 652 - copyBtn.style.background = ''; 653 - copyBtn.style.color = ''; 654 - }, 2000); 655 - } 656 - </script> 657 - </body> 658 - </html>
+1046
cdn/index.html
··· 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>MBD CDN - madebydanny.uk</title> 7 + <script src="https://kit.fontawesome.com/0ca27f8db1.js" crossorigin="anonymous"></script> 8 + <link rel="icon" href="https://public-cdn.madebydanny.uk/user-content/2026-01-30/33913bec-bc2f-4e6c-a474-2ef8f8c00197"> 9 + <meta name="description" content="The MBD CDN is a network of servers located around the world powered by the Cloudflare global network."> 10 + <meta property="og:title" content="MBD CDN - madebydanny.uk"> 11 + <meta property="og:description" content="The MBD CDN is a network of servers located around the world powered by the Cloudflare global network."> 12 + <meta property="og:image" content="https://imrs.madebydanny.uk?url=https://public-cdn.madebydanny.uk/user-content/2026-01-30/cb09a559-ae35-4617-971c-9230521f7a9c.png"> 13 + <meta property="og:type" content="website"> 14 + 15 + <style> 16 + :root { 17 + --bg: #121212; 18 + --card-bg: #4a2b32; 19 + --post-bg: #1e1e1e; 20 + --stat-bg: #2a1a21; 21 + --text: #ffffff; 22 + --subtext: #dcbaba; 23 + --link: #ffcccc; 24 + --border: rgba(255,255,255,0.1); 25 + --green: #34c759; 26 + --red: #ff6b6b; 27 + } 28 + 29 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 30 + 31 + body { 32 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 33 + background: var(--bg); 34 + color: var(--text); 35 + min-height: 100vh; 36 + line-height: 1.5; 37 + } 38 + 39 + a { color: var(--link); text-decoration: none; } 40 + a:hover { text-decoration: underline; color: #ffd9d9; } 41 + 42 + /* ── HEADER ─────────────────────────────── */ 43 + .site-header { 44 + text-align: center; 45 + padding: 44px 20px 10px; 46 + max-width: 900px; 47 + margin: 0 auto; 48 + } 49 + 50 + .site-header h1 { 51 + font-size: 2.2rem; 52 + font-weight: 700; 53 + letter-spacing: -1px; 54 + margin-bottom: 10px; 55 + } 56 + 57 + .site-header p { 58 + color: var(--subtext); 59 + font-size: 0.95rem; 60 + max-width: 560px; 61 + margin: 0 auto; 62 + } 63 + 64 + /* ── STATS ──────────────────────────────── */ 65 + .stats-wrap { 66 + max-width: 900px; 67 + margin: 30px auto 0; 68 + padding: 0 20px; 69 + } 70 + 71 + .stats-grid { 72 + display: grid; 73 + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); 74 + gap: 14px; 75 + } 76 + 77 + .stat-card { 78 + background: linear-gradient(135deg, var(--stat-bg) 0%, var(--card-bg) 100%); 79 + border: 1px solid var(--border); 80 + border-radius: 12px; 81 + padding: 20px; 82 + text-align: center; 83 + position: relative; 84 + overflow: hidden; 85 + transition: transform 0.2s, box-shadow 0.2s; 86 + } 87 + 88 + .stat-card::before { 89 + content: ''; 90 + position: absolute; 91 + top: 0; left: 0; right: 0; 92 + height: 3px; 93 + background: linear-gradient(90deg, var(--link), rgba(255,204,204,0.25)); 94 + opacity: 0; 95 + transition: opacity 0.3s; 96 + } 97 + 98 + .stat-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,0.4); } 99 + .stat-card:hover::before { opacity: 1; } 100 + 101 + .stat-icon { font-size: 1.8rem; margin-bottom: 8px; opacity: 0.75; } 102 + 103 + .stat-value { 104 + font-size: 2rem; 105 + font-weight: 700; 106 + letter-spacing: -1px; 107 + margin: 4px 0; 108 + } 109 + 110 + .stat-value.loading { opacity: 0.35; animation: blink 1.4s ease-in-out infinite; } 111 + @keyframes blink { 0%,100%{opacity:.35} 50%{opacity:.7} } 112 + 113 + .stat-label { 114 + font-size: 0.8rem; 115 + color: var(--subtext); 116 + text-transform: uppercase; 117 + letter-spacing: 0.5px; 118 + font-weight: 600; 119 + } 120 + 121 + /* ── TABS ───────────────────────────────── */ 122 + .tabs-wrap { 123 + max-width: 900px; 124 + margin: 36px auto 0; 125 + padding: 0 20px; 126 + } 127 + 128 + .tab-bar { 129 + display: flex; 130 + gap: 4px; 131 + border-bottom: 1px solid var(--border); 132 + overflow-x: auto; 133 + scrollbar-width: none; 134 + } 135 + 136 + .tab-bar::-webkit-scrollbar { display: none; } 137 + 138 + .tab-btn { 139 + font-family: inherit; 140 + font-size: 0.9rem; 141 + font-weight: 600; 142 + color: var(--subtext); 143 + background: none; 144 + border: none; 145 + border-bottom: 2px solid transparent; 146 + padding: 10px 18px; 147 + cursor: pointer; 148 + white-space: nowrap; 149 + transition: color 0.2s, border-color 0.2s; 150 + margin-bottom: -1px; 151 + } 152 + 153 + .tab-btn:hover { color: var(--text); } 154 + .tab-btn.active { color: var(--link); border-bottom-color: var(--link); } 155 + 156 + .tab-content { padding: 32px 0 80px; } 157 + 158 + .tab-pane { display: none; } 159 + .tab-pane.active { display: block; } 160 + 161 + /* ── CARD ───────────────────────────────── */ 162 + .card { 163 + background: var(--card-bg); 164 + border: 1px solid var(--border); 165 + border-radius: 16px; 166 + padding: 32px; 167 + box-shadow: 0 6px 12px rgba(0,0,0,0.35); 168 + } 169 + 170 + .card + .card { margin-top: 20px; } 171 + 172 + .card h2 { 173 + font-size: 1.25rem; 174 + font-weight: 700; 175 + letter-spacing: -0.4px; 176 + margin-bottom: 8px; 177 + } 178 + 179 + .card .desc { 180 + color: var(--subtext); 181 + font-size: 0.9rem; 182 + margin-bottom: 20px; 183 + } 184 + 185 + hr.divider { 186 + border: none; 187 + border-top: 1px solid var(--border); 188 + margin: 28px 0; 189 + } 190 + 191 + /* ── UPLOAD ─────────────────────────────── */ 192 + .drop-zone { 193 + display: block; 194 + border: 2px dashed var(--border); 195 + border-radius: 12px; 196 + padding: 50px 20px; 197 + text-align: center; 198 + cursor: pointer; 199 + background: rgba(0,0,0,0.2); 200 + color: var(--subtext); 201 + transition: all 0.25s; 202 + } 203 + 204 + .drop-zone:hover { 205 + border-color: var(--link); 206 + background: rgba(255,255,255,0.02); 207 + transform: scale(1.005); 208 + } 209 + 210 + .drop-zone.drag-over { 211 + border-color: var(--green); 212 + background: rgba(52,199,89,0.05); 213 + transform: scale(1.01); 214 + } 215 + 216 + .drop-zone i { font-size: 2.4rem; display: block; margin-bottom: 12px; opacity: 0.65; } 217 + .drop-zone input { display: none; } 218 + 219 + #file-name { font-size: 0.95rem; font-weight: 500; display: block; margin-top: 4px; } 220 + 221 + .file-info { 222 + display: none; 223 + margin-top: 14px; 224 + padding: 12px 16px; 225 + background: var(--post-bg); 226 + border: 1px solid var(--border); 227 + border-radius: 8px; 228 + font-size: 0.82rem; 229 + color: var(--subtext); 230 + } 231 + 232 + .file-info.show { display: block; } 233 + 234 + .progress-wrap { display: none; margin-top: 14px; } 235 + .progress-wrap.show { display: block; } 236 + 237 + .progress-track { 238 + height: 7px; 239 + background: var(--post-bg); 240 + border-radius: 999px; 241 + overflow: hidden; 242 + border: 1px solid var(--border); 243 + } 244 + 245 + .progress-fill { 246 + height: 100%; 247 + width: 0%; 248 + background: linear-gradient(90deg, var(--link), var(--green)); 249 + border-radius: 999px; 250 + transition: width 0.2s ease; 251 + } 252 + 253 + .progress-label { 254 + text-align: right; 255 + font-size: 0.75rem; 256 + color: var(--subtext); 257 + margin-top: 5px; 258 + } 259 + 260 + .upload-btn { 261 + display: block; 262 + width: 100%; 263 + margin-top: 18px; 264 + padding: 14px 24px; 265 + background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%); 266 + color: #000; 267 + border: none; 268 + border-radius: 999px; 269 + font-family: inherit; 270 + font-size: 1rem; 271 + font-weight: 600; 272 + cursor: pointer; 273 + box-shadow: 0 2px 8px rgba(255,255,255,0.08); 274 + transition: all 0.2s; 275 + } 276 + 277 + .upload-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 14px rgba(255,255,255,0.18); } 278 + .upload-btn:active { transform: scale(0.985); } 279 + .upload-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; } 280 + 281 + .status-msg { 282 + text-align: center; 283 + margin-top: 14px; 284 + font-size: 0.9rem; 285 + font-weight: 500; 286 + min-height: 1.3em; 287 + color: var(--link); 288 + } 289 + 290 + .result-box { 291 + display: none; 292 + margin-top: 18px; 293 + padding: 18px; 294 + background: var(--post-bg); 295 + border: 1px solid var(--green); 296 + border-radius: 12px; 297 + animation: slideIn 0.3s ease; 298 + } 299 + 300 + .result-box.show { display: block; } 301 + 302 + @keyframes slideIn { 303 + from { opacity: 0; transform: translateY(-8px); } 304 + to { opacity: 1; transform: translateY(0); } 305 + } 306 + 307 + .result-label { 308 + font-size: 0.72rem; 309 + font-weight: 700; 310 + text-transform: uppercase; 311 + letter-spacing: 0.6px; 312 + color: var(--subtext); 313 + margin-bottom: 8px; 314 + } 315 + 316 + .result-url { 317 + font-family: 'Monaco', 'Courier New', monospace; 318 + font-size: 0.85rem; 319 + padding: 10px 12px; 320 + background: rgba(0,0,0,0.3); 321 + border: 1px solid var(--border); 322 + border-radius: 6px; 323 + word-break: break-all; 324 + color: var(--text); 325 + margin-bottom: 12px; 326 + } 327 + 328 + .copy-btn { 329 + display: inline-flex; 330 + align-items: center; 331 + gap: 7px; 332 + padding: 9px 16px; 333 + background: rgba(255,255,255,0.05); 334 + border: 1px solid var(--border); 335 + border-radius: 8px; 336 + color: var(--link); 337 + font-family: inherit; 338 + font-size: 0.85rem; 339 + font-weight: 600; 340 + cursor: pointer; 341 + transition: all 0.15s; 342 + margin-right: 8px; 343 + } 344 + 345 + .copy-btn:hover { background: rgba(255,255,255,0.09); } 346 + .copy-btn.copied { background: var(--green); color: #000; border-color: var(--green); } 347 + 348 + .open-btn { 349 + display: inline-flex; 350 + align-items: center; 351 + gap: 7px; 352 + padding: 9px 16px; 353 + background: rgba(255,255,255,0.05); 354 + border: 1px solid var(--border); 355 + border-radius: 8px; 356 + color: var(--link); 357 + font-size: 0.85rem; 358 + font-weight: 600; 359 + transition: all 0.15s; 360 + } 361 + 362 + .open-btn:hover { background: rgba(255,255,255,0.09); text-decoration: none; } 363 + 364 + /* ── ABOUT ──────────────────────────────── */ 365 + .about-text { 366 + color: var(--subtext); 367 + font-size: 0.92rem; 368 + line-height: 1.7; 369 + } 370 + 371 + .about-text p + p { margin-top: 12px; } 372 + 373 + .social-row { 374 + display: flex; 375 + flex-wrap: wrap; 376 + gap: 10px; 377 + justify-content: center; 378 + } 379 + 380 + .social-btn { 381 + display: inline-flex; 382 + align-items: center; 383 + gap: 8px; 384 + padding: 9px 16px; 385 + background: rgba(255,255,255,0.04); 386 + border: 1px solid var(--border); 387 + border-radius: 999px; 388 + color: var(--text); 389 + font-size: 0.88rem; 390 + font-weight: 600; 391 + transition: all 0.2s; 392 + } 393 + 394 + .social-btn:hover { 395 + background: rgba(255,255,255,0.08); 396 + transform: translateY(-2px); 397 + box-shadow: 0 4px 12px rgba(0,0,0,0.3); 398 + text-decoration: none; 399 + } 400 + 401 + /* ── HOW IT WORKS ───────────────────────── */ 402 + .steps { 403 + display: flex; 404 + flex-direction: column; 405 + gap: 18px; 406 + margin-bottom: 28px; 407 + } 408 + 409 + .step { 410 + display: flex; 411 + align-items: flex-start; 412 + gap: 16px; 413 + } 414 + 415 + .step-num { 416 + flex-shrink: 0; 417 + width: 34px; height: 34px; 418 + border-radius: 50%; 419 + background: linear-gradient(135deg, var(--stat-bg), var(--card-bg)); 420 + border: 1px solid var(--border); 421 + display: flex; 422 + align-items: center; 423 + justify-content: center; 424 + font-size: 0.78rem; 425 + font-weight: 700; 426 + color: var(--link); 427 + } 428 + 429 + .step-body h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 3px; } 430 + .step-body p { color: var(--subtext); font-size: 0.85rem; line-height: 1.55; } 431 + 432 + .how-grid { 433 + display: grid; 434 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 435 + gap: 14px; 436 + } 437 + 438 + .how-card { 439 + background: var(--post-bg); 440 + border: 1px solid var(--border); 441 + border-radius: 10px; 442 + padding: 18px; 443 + } 444 + 445 + .how-card i { font-size: 1.5rem; color: var(--link); margin-bottom: 10px; display: block; } 446 + .how-card h3 { font-size: 0.9rem; font-weight: 700; margin-bottom: 5px; } 447 + .how-card p { font-size: 0.8rem; color: var(--subtext); line-height: 1.55; } 448 + 449 + /* ── LIMITS ─────────────────────────────── */ 450 + .limits-grid { 451 + display: grid; 452 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 453 + gap: 14px; 454 + margin-bottom: 22px; 455 + } 456 + 457 + .limit-card { 458 + background: var(--post-bg); 459 + border: 1px solid var(--border); 460 + border-radius: 12px; 461 + padding: 22px 18px; 462 + text-align: center; 463 + position: relative; 464 + overflow: hidden; 465 + } 466 + 467 + .limit-card::before { 468 + content: ''; 469 + position: absolute; 470 + top: 0; left: 0; right: 0; 471 + height: 3px; 472 + background: linear-gradient(90deg, var(--link), rgba(255,204,204,0.2)); 473 + } 474 + 475 + .limit-card i { font-size: 1.8rem; color: var(--link); margin-bottom: 12px; display: block; opacity: 0.8; } 476 + .limit-value { font-size: 1.7rem; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 4px; } 477 + .limit-label { font-size: 0.82rem; color: var(--subtext); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; } 478 + .limit-note { margin-top: 5px; font-size: 0.72rem; color: rgba(220,186,186,0.5); } 479 + 480 + /* ── USAGE BARS ─────────────────────────── */ 481 + .usage-section { margin-top: 28px; } 482 + 483 + .usage-section h2 { font-size: 1rem; font-weight: 700; margin-bottom: 16px; } 484 + 485 + .usage-item { margin-bottom: 16px; } 486 + 487 + .usage-row { 488 + display: flex; 489 + justify-content: space-between; 490 + font-size: 0.82rem; 491 + color: var(--subtext); 492 + margin-bottom: 6px; 493 + } 494 + 495 + .usage-row span:first-child { font-weight: 600; color: var(--text); } 496 + 497 + .usage-track { 498 + height: 8px; 499 + background: var(--post-bg); 500 + border-radius: 999px; 501 + overflow: hidden; 502 + border: 1px solid var(--border); 503 + } 504 + 505 + .usage-fill { 506 + height: 100%; 507 + border-radius: 999px; 508 + background: linear-gradient(90deg, var(--link), var(--green)); 509 + transition: width 0.6s cubic-bezier(.4,0,.2,1); 510 + } 511 + 512 + .usage-fill.warn { background: linear-gradient(90deg, #ffaa00, #ff6b00); } 513 + .usage-fill.danger { background: linear-gradient(90deg, var(--red), #cc0000); } 514 + 515 + .usage-loading { font-size: 0.8rem; color: var(--subtext); opacity: 0.5; } 516 + 517 + .limits-note { 518 + padding: 14px 18px; 519 + background: rgba(255,107,107,0.06); 520 + border: 1px solid rgba(255,107,107,0.18); 521 + border-radius: 10px; 522 + font-size: 0.83rem; 523 + color: var(--subtext); 524 + line-height: 1.6; 525 + } 526 + 527 + .limits-note i { color: var(--red); margin-right: 4px; } 528 + 529 + /* ── FOOTER ─────────────────────────────── */ 530 + .site-footer { 531 + text-align: center; 532 + padding: 0 20px 32px; 533 + font-size: 0.82rem; 534 + color: var(--subtext); 535 + } 536 + 537 + /* ── RESPONSIVE ─────────────────────────── */ 538 + @media (max-width: 600px) { 539 + .stats-grid { grid-template-columns: repeat(2, 1fr); } 540 + .site-header h1 { font-size: 1.8rem; } 541 + .stat-value { font-size: 1.5rem; } 542 + .how-grid, .limits-grid { grid-template-columns: 1fr; } 543 + .card { padding: 22px 18px; } 544 + } 545 + </style> 546 + </head> 547 + <body> 548 + 549 + <header class="site-header"> 550 + <h1><i class="fa-solid fa-database" style="color:#fff"></i> MBD CDN</h1> 551 + <p>A network of servers located around the world powered by the Cloudflare global network — delivering media at extremely fast speeds with 100% uptime.</p> 552 + </header> 553 + 554 + <!-- STATS --> 555 + <div class="stats-wrap"> 556 + <div class="stats-grid"> 557 + <div class="stat-card"> 558 + <div class="stat-icon"><i class="fa-regular fa-image"></i></div> 559 + <div class="stat-value loading" id="stat-images">--</div> 560 + <div class="stat-label">Images</div> 561 + </div> 562 + <div class="stat-card"> 563 + <div class="stat-icon"><i class="fa-solid fa-video"></i></div> 564 + <div class="stat-value loading" id="stat-videos">--</div> 565 + <div class="stat-label">Videos</div> 566 + </div> 567 + <div class="stat-card"> 568 + <div class="stat-icon"><i class="fa-solid fa-photo-film"></i></div> 569 + <div class="stat-value loading" id="stat-gifs">--</div> 570 + <div class="stat-label">GIFs</div> 571 + </div> 572 + <div class="stat-card"> 573 + <div class="stat-icon"><i class="fa-solid fa-database"></i></div> 574 + <div class="stat-value loading" id="stat-storage">--</div> 575 + <div class="stat-label">Storage Used</div> 576 + </div> 577 + </div> 578 + </div> 579 + 580 + <!-- TABS --> 581 + <div class="tabs-wrap"> 582 + <div class="tab-bar"> 583 + <button class="tab-btn active" onclick="switchTab('upload', this)"> 584 + <i class="fa-solid fa-cloud-arrow-up"></i> Upload 585 + </button> 586 + <button class="tab-btn" onclick="switchTab('about', this)"> 587 + <i class="fa-solid fa-circle-info"></i> About 588 + </button> 589 + <button class="tab-btn" onclick="switchTab('how', this)"> 590 + <i class="fa-solid fa-gears"></i> How it Works 591 + </button> 592 + <button class="tab-btn" onclick="switchTab('limits', this)"> 593 + <i class="fa-solid fa-gauge-high"></i> Limits 594 + </button> 595 + </div> 596 + 597 + <div class="tab-content"> 598 + 599 + <!-- UPLOAD --> 600 + <div class="tab-pane active" id="tab-upload"> 601 + <div class="card"> 602 + <h2>Upload a File</h2> 603 + <p class="desc">Images, GIFs, and videos accepted. Files are served globally via Cloudflare immediately after upload.</p> 604 + 605 + <label class="drop-zone" id="drop-zone" for="file-input"> 606 + <i class="fa-solid fa-cloud-arrow-up" id="drop-icon"></i> 607 + <span id="file-name">Click to select or drag a file here</span> 608 + <input type="file" id="file-input" accept="image/*,video/*"> 609 + </label> 610 + 611 + <div class="file-info" id="file-info"> 612 + <div><b>File:</b> <span id="detail-name"></span></div> 613 + <div style="margin-top:4px"><b>Size:</b> <span id="detail-size"></span> &nbsp;·&nbsp; <b>Type:</b> <span id="detail-type"></span></div> 614 + </div> 615 + 616 + <div class="progress-wrap" id="progress-wrap"> 617 + <div class="progress-track"> 618 + <div class="progress-fill" id="progress-fill"></div> 619 + </div> 620 + <div class="progress-label" id="progress-label">0%</div> 621 + </div> 622 + 623 + <button class="upload-btn" id="upload-btn"> 624 + <i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN 625 + </button> 626 + 627 + <div class="status-msg" id="status"></div> 628 + 629 + <div class="result-box" id="result-box"> 630 + <div class="result-label">✅ Public URL</div> 631 + <div class="result-url" id="result-url"></div> 632 + <button class="copy-btn" id="copy-btn" onclick="copyUrl()"> 633 + <i class="fa-solid fa-copy"></i> Copy URL 634 + </button> 635 + <a class="open-btn" id="open-link" href="#" target="_blank" rel="noopener"> 636 + <i class="fa-solid fa-arrow-up-right-from-square"></i> Open 637 + </a> 638 + </div> 639 + </div> 640 + </div> 641 + 642 + <!-- ABOUT --> 643 + <div class="tab-pane" id="tab-about"> 644 + <div class="card"> 645 + <h2>About MBD CDN</h2> 646 + <div class="about-text"> 647 + <p>The MBD CDN is a content delivery network built by <a href="https://madebydanny.uk" target="_blank">madebydanny.uk</a> to host and serve media files — images, GIFs, and videos — at extremely fast speeds with global availability.</p> 648 + <p>Files uploaded to the CDN are stored in Cloudflare R2 object storage and served from Cloudflare's global edge network, which spans over 310 cities worldwide. This means your media is delivered from a server geographically close to whoever is viewing it — minimising latency and maximising load speed.</p> 649 + <p>The platform is designed to be simple and permanent. Files are stored indefinitely once uploaded and are immediately available via a public URL.</p> 650 + </div> 651 + 652 + <hr class="divider"> 653 + 654 + <h2 style="margin-bottom:16px;">Social Links</h2> 655 + <div id="social-links" class="social-row"></div> 656 + </div> 657 + </div> 658 + 659 + <!-- HOW IT WORKS --> 660 + <div class="tab-pane" id="tab-how"> 661 + <div class="card"> 662 + <h2>How it Works</h2> 663 + <p class="desc">From the moment you click Upload to the moment someone loads your file — here's what happens.</p> 664 + 665 + <div class="steps"> 666 + <div class="step"> 667 + <div class="step-num">1</div> 668 + <div class="step-body"> 669 + <h3>You select a file</h3> 670 + <p>Your file is read locally in the browser and sent directly to the CDN API over HTTPS. It goes straight to the Cloudflare edge — no intermediate servers involved.</p> 671 + </div> 672 + </div> 673 + <div class="step"> 674 + <div class="step-num">2</div> 675 + <div class="step-body"> 676 + <h3>The Worker receives it</h3> 677 + <p>A Cloudflare Worker handles the upload at the edge. It assigns a UUID filename, detects the file type, and streams the body directly into R2 object storage — with no cold starts and near-instant response times.</p> 678 + </div> 679 + </div> 680 + <div class="step"> 681 + <div class="step-num">3</div> 682 + <div class="step-body"> 683 + <h3>R2 stores it permanently</h3> 684 + <p>The file is written to Cloudflare R2 — S3-compatible storage with zero egress fees and 11 nines of durability. Upload metadata is logged to D1 (Cloudflare's edge SQL database) to track stats.</p> 685 + </div> 686 + </div> 687 + <div class="step"> 688 + <div class="step-num">4</div> 689 + <div class="step-body"> 690 + <h3>You get a public URL</h3> 691 + <p>A permanent <code style="font-size:0.8rem;color:var(--link)">public-cdn.madebydanny.uk</code> link is returned instantly. Anyone with it can access the file — served from whichever Cloudflare PoP is closest to them.</p> 692 + </div> 693 + </div> 694 + </div> 695 + 696 + <div class="how-grid"> 697 + <div class="how-card"> 698 + <i class="fa-brands fa-cloudflare"></i> 699 + <h3>310+ Edge Locations</h3> 700 + <p>Files are cached and served globally — sub-50ms for most users regardless of where they are.</p> 701 + </div> 702 + <div class="how-card"> 703 + <i class="fa-solid fa-database"></i> 704 + <h3>R2 Object Storage</h3> 705 + <p>Zero egress fees, no expiry. Files are stored in Cloudflare R2 with enterprise-grade durability.</p> 706 + </div> 707 + <div class="how-card"> 708 + <i class="fa-solid fa-bolt"></i> 709 + <h3>Zero Cold Starts</h3> 710 + <p>Workers run at the edge with no spin-up delay — every upload and file request is handled immediately.</p> 711 + </div> 712 + </div> 713 + </div> 714 + </div> 715 + 716 + <!-- LIMITS --> 717 + <div class="tab-pane" id="tab-limits"> 718 + <div class="card"> 719 + <h2>Usage Limits</h2> 720 + <p class="desc">Fair-use limits are in place to keep the CDN reliable and available for everyone.</p> 721 + 722 + <div class="limits-grid"> 723 + <div class="limit-card"> 724 + <i class="fa-solid fa-file-arrow-up"></i> 725 + <div class="limit-value" id="limit-max-file">100 MB</div> 726 + <div class="limit-label">Max File Size</div> 727 + <div class="limit-note">Per individual upload</div> 728 + </div> 729 + <div class="limit-card"> 730 + <i class="fa-solid fa-hard-drive"></i> 731 + <div class="limit-value" id="limit-max-bytes">—</div> 732 + <div class="limit-label">Daily Storage</div> 733 + <div class="limit-note">Total uploads per day</div> 734 + </div> 735 + <div class="limit-card"> 736 + <i class="fa-solid fa-arrow-up-from-bracket"></i> 737 + <div class="limit-value" id="limit-max-files">—</div> 738 + <div class="limit-label">Uploads Per Day</div> 739 + <div class="limit-note">Resets at midnight UTC</div> 740 + </div> 741 + </div> 742 + 743 + <div class="limits-note"> 744 + <i class="fa-solid fa-circle-exclamation"></i> 745 + All limits reset daily at <strong>midnight UTC</strong>. They are enforced per IP to protect performance for all users. If you need higher limits, consider self-hosting the stack or get in touch via the social links in the About section. 746 + </div> 747 + 748 + <div class="usage-section"> 749 + <h2>Your Usage Today</h2> 750 + <div id="usage-loading" class="usage-loading">Loading your usage…</div> 751 + <div id="usage-bars" style="display:none"> 752 + <div class="usage-item"> 753 + <div class="usage-row"> 754 + <span>Files Uploaded</span> 755 + <span id="usage-files-label">0 / 30</span> 756 + </div> 757 + <div class="usage-track"> 758 + <div class="usage-fill" id="usage-files-fill" style="width:0%"></div> 759 + </div> 760 + </div> 761 + <div class="usage-item" style="margin-top:14px"> 762 + <div class="usage-row"> 763 + <span>Storage Used</span> 764 + <span id="usage-bytes-label">0 B / 1 GB</span> 765 + </div> 766 + <div class="usage-track"> 767 + <div class="usage-fill" id="usage-bytes-fill" style="width:0%"></div> 768 + </div> 769 + </div> 770 + </div> 771 + </div> 772 + 773 + <hr class="divider"> 774 + 775 + <h2 style="font-size:1rem; margin-bottom:10px;">Accepted File Types</h2> 776 + <p style="color:var(--subtext); font-size:0.85rem; line-height:1.7;"> 777 + <strong style="color:var(--text)">Images:</strong> JPEG, PNG, WebP, AVIF, SVG &nbsp;&nbsp; 778 + <strong style="color:var(--text)">Animated:</strong> GIF &nbsp;&nbsp; 779 + <strong style="color:var(--text)">Video:</strong> MP4, WebM, MOV 780 + </p> 781 + </div> 782 + </div> 783 + 784 + </div> 785 + </div> 786 + 787 + <footer class="site-footer"> 788 + &copy; <script>document.write(new Date().getFullYear())</script> <a href="https://madebydanny.uk" target="_blank">madebydanny.uk</a> 789 + </footer> 790 + 791 + <script> 792 + const API = 'https://cdn.madebydanny.uk'; 793 + 794 + function formatBytes(b) { 795 + if (!b) return '0 B'; 796 + const k = 1024, s = ['B','KB','MB','GB','TB']; 797 + const i = Math.floor(Math.log(b) / Math.log(k)); 798 + return (b / Math.pow(k, i)).toFixed(1).replace(/\.0$/,'') + ' ' + s[i]; 799 + } 800 + 801 + function fmt(n) { return Number(n).toLocaleString(); } 802 + 803 + // ── TABS ───────────────────────────────────────────── 804 + function switchTab(name, btn) { 805 + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); 806 + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); 807 + document.getElementById('tab-' + name).classList.add('active'); 808 + if (btn) btn.classList.add('active'); 809 + if (name === 'limits') loadLimits(); 810 + } 811 + 812 + // ── STATS ───────────────────────────────────────────── 813 + async function loadStats() { 814 + try { 815 + const r = await fetch(`${API}/stats`); 816 + const d = await r.json(); 817 + if (d.success) { 818 + document.getElementById('stat-images').textContent = fmt(d.stats.images); 819 + document.getElementById('stat-videos').textContent = fmt(d.stats.videos); 820 + document.getElementById('stat-gifs').textContent = fmt(d.stats.gifs); 821 + document.getElementById('stat-storage').textContent = formatBytes(d.stats.totalSize); 822 + document.querySelectorAll('.stat-value').forEach(v => v.classList.remove('loading')); 823 + } 824 + } catch { 825 + document.querySelectorAll('.stat-value').forEach(v => { 826 + v.textContent = '—'; 827 + v.classList.remove('loading'); 828 + }); 829 + } 830 + } 831 + 832 + // ── LIMITS (live from API) ──────────────────────────── 833 + async function loadLimits() { 834 + try { 835 + const r = await fetch(`${API}/limits`); 836 + const d = await r.json(); 837 + if (!d.success) throw new Error(); 838 + 839 + const { file_count, total_size, max_files, max_bytes, max_file } = d.limits; 840 + 841 + document.getElementById('limit-max-file').textContent = formatBytes(max_file); 842 + document.getElementById('limit-max-bytes').textContent = formatBytes(max_bytes); 843 + document.getElementById('limit-max-files').textContent = max_files; 844 + 845 + const filePct = Math.min((file_count / max_files) * 100, 100); 846 + const bytesPct = Math.min((total_size / max_bytes) * 100, 100); 847 + 848 + const filesFill = document.getElementById('usage-files-fill'); 849 + const bytesFill = document.getElementById('usage-bytes-fill'); 850 + 851 + filesFill.style.width = filePct + '%'; 852 + bytesFill.style.width = bytesPct + '%'; 853 + 854 + function fillClass(pct) { 855 + if (pct >= 90) return 'danger'; 856 + if (pct >= 70) return 'warn'; 857 + return ''; 858 + } 859 + 860 + filesFill.className = 'usage-fill ' + fillClass(filePct); 861 + bytesFill.className = 'usage-fill ' + fillClass(bytesPct); 862 + 863 + document.getElementById('usage-files-label').textContent = 864 + `${fmt(file_count)} / ${fmt(max_files)}`; 865 + document.getElementById('usage-bytes-label').textContent = 866 + `${formatBytes(total_size)} / ${formatBytes(max_bytes)}`; 867 + 868 + document.getElementById('usage-loading').style.display = 'none'; 869 + document.getElementById('usage-bars').style.display = 'block'; 870 + 871 + } catch { 872 + document.getElementById('usage-loading').textContent = 'Could not load usage data.'; 873 + } 874 + } 875 + 876 + // ── FILE SELECT ─────────────────────────────────────── 877 + const fileInput = document.getElementById('file-input'); 878 + const dropZone = document.getElementById('drop-zone'); 879 + const fileInfo = document.getElementById('file-info'); 880 + 881 + function showFile(file) { 882 + const type = file.type || ''; 883 + const icon = document.getElementById('drop-icon'); 884 + 885 + // Update drop zone icon to match file type 886 + if (type.startsWith('video/')) { 887 + icon.className = 'fa-solid fa-film'; 888 + } else if (type === 'image/gif') { 889 + icon.className = 'fa-solid fa-photo-film'; 890 + } else if (type.startsWith('image/')) { 891 + icon.className = 'fa-regular fa-image'; 892 + } else { 893 + icon.className = 'fa-solid fa-cloud-arrow-up'; 894 + } 895 + 896 + document.getElementById('file-name').textContent = file.name; 897 + document.getElementById('detail-name').textContent = file.name; 898 + document.getElementById('detail-size').textContent = formatBytes(file.size); 899 + document.getElementById('detail-type').textContent = type || 'Unknown'; 900 + fileInfo.classList.add('show'); 901 + } 902 + 903 + fileInput.addEventListener('change', () => { 904 + if (fileInput.files[0]) showFile(fileInput.files[0]); 905 + }); 906 + 907 + dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); 908 + dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); }); 909 + dropZone.addEventListener('drop', e => { 910 + e.preventDefault(); 911 + dropZone.classList.remove('drag-over'); 912 + const f = e.dataTransfer.files[0]; 913 + if (f) { fileInput.files = e.dataTransfer.files; showFile(f); } 914 + }); 915 + 916 + // ── UPLOAD (XHR for real progress) ─────────────────── 917 + const uploadBtn = document.getElementById('upload-btn'); 918 + const progressWrap = document.getElementById('progress-wrap'); 919 + const progressFill = document.getElementById('progress-fill'); 920 + const progressLabel= document.getElementById('progress-label'); 921 + const statusEl = document.getElementById('status'); 922 + const resultBox = document.getElementById('result-box'); 923 + const resultUrl = document.getElementById('result-url'); 924 + 925 + function setStatus(msg, color) { 926 + statusEl.textContent = msg; 927 + statusEl.style.color = color || 'var(--link)'; 928 + } 929 + 930 + function setProgress(pct) { 931 + progressFill.style.width = pct + '%'; 932 + progressLabel.textContent = Math.round(pct) + '%'; 933 + } 934 + 935 + function resetUploadUI(delay = 0) { 936 + setTimeout(() => { 937 + fileInput.value = ''; 938 + document.getElementById('drop-icon').className = 'fa-solid fa-cloud-arrow-up'; 939 + document.getElementById('file-name').textContent = 'Click to select or drag a file here'; 940 + fileInfo.classList.remove('show'); 941 + progressWrap.classList.remove('show'); 942 + setProgress(0); 943 + }, delay); 944 + } 945 + 946 + uploadBtn.addEventListener('click', () => { 947 + if (!fileInput.files[0]) { 948 + setStatus('⚠️ Please select a file first.', 'var(--red)'); 949 + return; 950 + } 951 + 952 + const file = fileInput.files[0]; 953 + uploadBtn.disabled = true; 954 + uploadBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading…'; 955 + setStatus(''); 956 + resultBox.classList.remove('show'); 957 + progressWrap.classList.add('show'); 958 + setProgress(0); 959 + 960 + const xhr = new XMLHttpRequest(); 961 + 962 + xhr.upload.addEventListener('progress', e => { 963 + if (e.lengthComputable) setProgress((e.loaded / e.total) * 100); 964 + }); 965 + 966 + xhr.addEventListener('load', () => { 967 + setProgress(100); 968 + try { 969 + const data = JSON.parse(xhr.responseText); 970 + if (data.success) { 971 + resultUrl.textContent = data.url; 972 + document.getElementById('open-link').href = data.url; 973 + resultBox.classList.add('show'); 974 + setStatus(''); 975 + setTimeout(() => loadStats(), 600); 976 + resetUploadUI(3000); 977 + } else { 978 + throw new Error(data.error || 'Upload failed'); 979 + } 980 + } catch (err) { 981 + progressWrap.classList.remove('show'); 982 + setStatus('❌ ' + err.message, 'var(--red)'); 983 + } 984 + 985 + uploadBtn.disabled = false; 986 + uploadBtn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN'; 987 + }); 988 + 989 + xhr.addEventListener('error', () => { 990 + progressWrap.classList.remove('show'); 991 + setStatus('❌ Network error. Please try again.', 'var(--red)'); 992 + uploadBtn.disabled = false; 993 + uploadBtn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN'; 994 + }); 995 + 996 + xhr.addEventListener('abort', () => { 997 + progressWrap.classList.remove('show'); 998 + setStatus('Upload cancelled.', 'var(--subtext)'); 999 + uploadBtn.disabled = false; 1000 + uploadBtn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Upload to MBD CDN'; 1001 + }); 1002 + 1003 + xhr.open('POST', API); 1004 + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); 1005 + xhr.send(file); 1006 + }); 1007 + 1008 + // ── COPY ───────────────────────────────────────────── 1009 + function copyUrl() { 1010 + navigator.clipboard.writeText(resultUrl.textContent); 1011 + const btn = document.getElementById('copy-btn'); 1012 + btn.classList.add('copied'); 1013 + btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied!'; 1014 + setTimeout(() => { 1015 + btn.classList.remove('copied'); 1016 + btn.innerHTML = '<i class="fa-solid fa-copy"></i> Copy URL'; 1017 + }, 2000); 1018 + } 1019 + 1020 + // ── SOCIAL LINKS FALLBACK ───────────────────────────── 1021 + window.addEventListener('load', () => { 1022 + const el = document.getElementById('social-links'); 1023 + if (!el.children.length) { 1024 + el.innerHTML = ` 1025 + <a class="social-btn" href="https://aturi.to/did:plc:l37td5yhxl2irrzrgvei4qay" target="_blank" rel="noopener"> 1026 + <i class="fa-brands fa-bluesky"></i> Bluesky 1027 + </a>`; 1028 + } 1029 + }); 1030 + 1031 + // ── INIT ───────────────────────────────────────────── 1032 + loadStats(); 1033 + </script> 1034 + 1035 + <!-- social-links.js is optional; silence 404s by loading it async --> 1036 + <script> 1037 + (function() { 1038 + const s = document.createElement('script'); 1039 + s.src = '/js/social-links.js'; 1040 + s.onerror = () => {}; 1041 + document.body.appendChild(s); 1042 + })(); 1043 + </script> 1044 + 1045 + </body> 1046 + </html>
+269
cdn/worker.js
··· 1 + /** 2 + * MBD CDN — Cloudflare Worker 3 + * 4 + * Endpoints: 5 + * POST / — upload file (enforces per-IP limits) 6 + * GET /stats — global stats (images / videos / gifs / totalSize) 7 + * GET /limits — today's usage for the requesting IP 8 + * 9 + * D1 tables required: 10 + * uploads (id, filename, path, content_type, file_type, size, upload_date) 11 + * upload_limits (ip TEXT, date TEXT, file_count INT DEFAULT 0, total_size INT DEFAULT 0, PRIMARY KEY (ip, date)) 12 + */ 13 + 14 + // ── LIMIT CONSTANTS ──────────────────────────────────────────────────────── 15 + const MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 MB per file 16 + const MAX_DAY_BYTES = 1024 * 1024 * 1024; // 1 GB per IP per day 17 + const MAX_DAY_FILES = 30; // 30 files per IP per day 18 + 19 + export default { 20 + async fetch(request, env, ctx) { 21 + const url = new URL(request.url); 22 + 23 + const cors = { 24 + "Access-Control-Allow-Origin": "*", 25 + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 26 + "Access-Control-Allow-Headers": "Content-Type", 27 + }; 28 + 29 + const json = (data, status = 200, extra = {}) => 30 + new Response(JSON.stringify(data), { 31 + status, 32 + headers: { "Content-Type": "application/json", ...cors, ...extra }, 33 + }); 34 + 35 + // ── Sanity check bindings ─────────────────── 36 + if (!env.DB) { 37 + return json({ success: false, error: "DB binding not configured" }, 500); 38 + } 39 + if (!env.MY_BUCKET) { 40 + return json({ success: false, error: "MY_BUCKET binding not configured" }, 500); 41 + } 42 + 43 + // ── CORS preflight ────────────────────────────── 44 + if (request.method === "OPTIONS") { 45 + return new Response(null, { headers: cors }); 46 + } 47 + 48 + // ── GET /stats ────────────────────────────────── 49 + if (request.method === "GET" && url.pathname === "/stats") { 50 + try { 51 + const stats = await getStatistics(env); 52 + return json( 53 + { success: true, stats }, 54 + 200, 55 + { "Cache-Control": "public, max-age=30, stale-while-revalidate=60" } 56 + ); 57 + } catch (e) { 58 + return json({ success: false, error: `Stats failed: ${e.message}` }, 500); 59 + } 60 + } 61 + 62 + // ── GET /limits ───────────────────────────────── 63 + if (request.method === "GET" && url.pathname === "/limits") { 64 + try { 65 + const ip = clientIp(request); 66 + const today = todayStr(); 67 + const row = await env.DB.prepare( 68 + `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` 69 + ).bind(ip, today).first(); 70 + 71 + return json( 72 + { 73 + success: true, 74 + limits: { 75 + file_count: row?.file_count || 0, 76 + total_size: row?.total_size || 0, 77 + max_files: MAX_DAY_FILES, 78 + max_bytes: MAX_DAY_BYTES, 79 + max_file: MAX_FILE_BYTES, 80 + }, 81 + }, 82 + 200, 83 + { "Cache-Control": "private, max-age=10" } 84 + ); 85 + } catch (e) { 86 + return json({ success: false, error: `Limits check failed: ${e.message}` }, 500); 87 + } 88 + } 89 + 90 + // ── POST / — Upload ───────────────────────────── 91 + if (request.method === "POST") { 92 + const ip = clientIp(request); 93 + const today = todayStr(); 94 + const contentType = request.headers.get("Content-Type") || "application/octet-stream"; 95 + const contentLength = parseInt(request.headers.get("Content-Length") || "0"); 96 + 97 + // ── 1. Per-file size check (fast, no I/O) ──── 98 + if (contentLength > MAX_FILE_BYTES) { 99 + return json({ 100 + success: false, 101 + error: `File too large. Max ${MAX_FILE_BYTES / 1024 / 1024} MB per file.`, 102 + }, 413); 103 + } 104 + 105 + try { 106 + const now = new Date(); 107 + const dateStr = now.toISOString().split("T")[0]; 108 + const randomId = crypto.randomUUID(); 109 + const fileInfo = getFileInfo(contentType); 110 + const path = `user-content/${dateStr}/${randomId}${fileInfo.extension}`; 111 + 112 + // ── 2. Race: DB limit check vs R2 upload ──── 113 + // request.body is a FixedLengthStream so R2 knows the content length. 114 + // obj.size gives us the real byte count after the write completes. 115 + const [usageResult, uploadResult] = await Promise.allSettled([ 116 + env.DB.prepare( 117 + `SELECT file_count, total_size FROM upload_limits WHERE ip = ? AND date = ?` 118 + ).bind(ip, today).first(), 119 + env.MY_BUCKET.put(path, request.body, { httpMetadata: { contentType } }), 120 + ]); 121 + 122 + // Bubble up R2 errors 123 + if (uploadResult.status === "rejected") { 124 + throw new Error(`R2 upload failed: ${uploadResult.reason?.message}`); 125 + } 126 + 127 + // obj.size is ground truth — fall back to Content-Length only if absent 128 + const actualSize = uploadResult.value?.size ?? contentLength; 129 + 130 + // ── 3. Enforce limits post-upload ─────────── 131 + const usage = usageResult.status === "fulfilled" ? usageResult.value : null; 132 + const usedFiles = usage?.file_count || 0; 133 + const usedBytes = usage?.total_size || 0; 134 + 135 + if (usedFiles >= MAX_DAY_FILES) { 136 + ctx.waitUntil(env.MY_BUCKET.delete(path)); 137 + return json({ 138 + success: false, 139 + error: `Daily file limit reached (${MAX_DAY_FILES} files/day). Resets at midnight UTC.`, 140 + }, 429); 141 + } 142 + 143 + if (usedBytes + actualSize > MAX_DAY_BYTES) { 144 + ctx.waitUntil(env.MY_BUCKET.delete(path)); 145 + const remaining = MAX_DAY_BYTES - usedBytes; 146 + return json({ 147 + success: false, 148 + error: `Daily storage limit reached. You have ${fmtBytes(remaining)} remaining today. Resets at midnight UTC.`, 149 + }, 429); 150 + } 151 + 152 + // ── 4. Batch both D1 writes in one round-trip 153 + ctx.waitUntil( 154 + env.DB.batch([ 155 + env.DB.prepare( 156 + `INSERT INTO uploads (id, filename, path, content_type, file_type, size, upload_date) 157 + VALUES (?, ?, ?, ?, ?, ?, ?)` 158 + ).bind( 159 + randomId, 160 + `${randomId}${fileInfo.extension}`, 161 + path, 162 + contentType, 163 + fileInfo.type, 164 + actualSize, 165 + now.toISOString() 166 + ), 167 + 168 + env.DB.prepare( 169 + `INSERT INTO upload_limits (ip, date, file_count, total_size) 170 + VALUES (?, ?, 1, ?) 171 + ON CONFLICT(ip, date) DO UPDATE SET 172 + file_count = file_count + 1, 173 + total_size = total_size + excluded.total_size` 174 + ).bind(ip, today, actualSize), 175 + ]) 176 + ); 177 + 178 + return json({ 179 + success: true, 180 + url: `https://public-cdn.madebydanny.uk/${path}`, 181 + path, 182 + contentType, 183 + fileType: fileInfo.type, 184 + size: actualSize, 185 + }); 186 + 187 + } catch (e) { 188 + return json({ success: false, error: `Upload failed: ${e.message}` }, 500); 189 + } 190 + } 191 + 192 + return json( 193 + { success: false, error: "Invalid request. POST to upload, GET /stats or GET /limits." }, 194 + 405 195 + ); 196 + }, 197 + }; 198 + 199 + // ── HELPERS ──────────────────────────────────────────────────────────────── 200 + 201 + /** Run all stat queries in parallel */ 202 + async function getStatistics(env) { 203 + const [images, videos, gifs, storage] = await Promise.all([ 204 + env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'image'`).first(), 205 + env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'video'`).first(), 206 + env.DB.prepare(`SELECT COUNT(*) AS c FROM uploads WHERE file_type = 'gif'`).first(), 207 + // COALESCE avoids NULL when the table is empty, which D1 handles more reliably 208 + env.DB.prepare(`SELECT COALESCE(SUM(size), 0) AS s FROM uploads`).first(), 209 + ]); 210 + 211 + return { 212 + images: images?.c || 0, 213 + videos: videos?.c || 0, 214 + gifs: gifs?.c || 0, 215 + totalSize: storage?.s || 0, 216 + totalFiles: (images?.c || 0) + (videos?.c || 0) + (gifs?.c || 0), 217 + }; 218 + } 219 + 220 + /** Best-effort client IP: Cloudflare header → fallback */ 221 + function clientIp(request) { 222 + return ( 223 + request.headers.get("CF-Connecting-IP") || 224 + request.headers.get("X-Forwarded-For")?.split(",")[0].trim() || 225 + "unknown" 226 + ); 227 + } 228 + 229 + /** Today's date as YYYY-MM-DD in UTC */ 230 + function todayStr() { 231 + return new Date().toISOString().split("T")[0]; 232 + } 233 + 234 + /** Human-readable bytes (for error messages) */ 235 + function fmtBytes(b) { 236 + if (!b) return "0 B"; 237 + const k = 1024, s = ["B", "KB", "MB", "GB", "TB"]; 238 + const i = Math.floor(Math.log(b) / Math.log(k)); 239 + return (b / Math.pow(k, i)).toFixed(1).replace(/\.0$/, "") + " " + s[i]; 240 + } 241 + 242 + /** Map content-type → { type, extension } */ 243 + function getFileInfo(contentType) { 244 + const t = contentType.toLowerCase(); 245 + 246 + if (t.includes("image/jpeg") || t.includes("image/jpg")) return { type: "image", extension: ".jpg" }; 247 + if (t.includes("image/png")) return { type: "image", extension: ".png" }; 248 + if (t.includes("image/gif")) return { type: "gif", extension: ".gif" }; 249 + if (t.includes("image/webp")) return { type: "image", extension: ".webp" }; 250 + if (t.includes("image/svg")) return { type: "image", extension: ".svg" }; 251 + if (t.includes("image/avif")) return { type: "image", extension: ".avif" }; 252 + 253 + if (t.includes("video/mp4")) return { type: "video", extension: ".mp4" }; 254 + if (t.includes("video/webm")) return { type: "video", extension: ".webm" }; 255 + if (t.includes("video/quicktime")) return { type: "video", extension: ".mov" }; 256 + if (t.includes("video/")) return { type: "video", extension: "" }; 257 + 258 + if (t.includes("application/pdf")) return { type: "document", extension: ".pdf" }; 259 + 260 + if (t.includes("audio/mpeg")) return { type: "audio", extension: ".mp3" }; 261 + if (t.includes("audio/ogg")) return { type: "audio", extension: ".ogg" }; 262 + if (t.includes("audio/wav")) return { type: "audio", extension: ".wav" }; 263 + if (t.includes("audio/")) return { type: "audio", extension: "" }; 264 + 265 + // Generic image catch-all comes last so it never swallows video/* types 266 + if (t.includes("image/")) return { type: "image", extension: "" }; 267 + 268 + return { type: "other", extension: "" }; 269 + }
+1 -1
index.html
··· 20 20 <div id="total-visitors" style="font-size: 0.8em; color: gray; text-align: center;"> 21 21 Calculating total visits... 22 22 </div> 23 - <p><a href="https://guestbook.madebydanny.uk">Guestbook</a> ~ <a href="https://pdsls.dev/at://did:plc:l37td5yhxl2irrzrgvei4qay/fm.teal.alpha.feed.play">Recently played Music</a> ~ <a href="/about.html">About Me</a> ~ <a href="https://microblog.madebydanny.uk">Microblog</a> ~ <a href="/photos.html">Photos</a></p> 23 + <p><a href="https://guestbook.madebydanny.uk/danny">Guestbook</a> ~ <a href="https://pdsls.dev/at://did:plc:l37td5yhxl2irrzrgvei4qay/fm.teal.alpha.feed.play">Recently played Music</a> ~ <a href="/about.html">About Me</a> ~ <a href="https://microblog.madebydanny.uk">Microblog</a> ~ <a href="/photos.html">Photos</a></p> 24 24 <p>I like to listen to Music <i>(Mainly Tate McRae and Taylor Swift)</i>, and post on Bluesky<br>I'm also on <a href="https://threads.net/@madebydanny.uk" target="_blank">Threads</a> and <a href="ttps://mastodon.social/@danielmorrisey" target="_blank">Mastodon</a>, but active on <a href="https://aturi.to/did:plc:l37td5yhxl2irrzrgvei4qay" target="_blank">Bluesky</a>, becuase it's the best social media platform</p> 25 25 <div id="music-status-card"> 26 26 <div class="bsky-header">
js/cdn-v2.js cdn/script.js