blazing fast link redirects on cloudflare kv hop.dunkirk.sh/u/tacy

bug: fix the virtual scrolling

dunkirk.sh bcdc009b 3373c34d

verified
+79 -103
+75 -103
src/index.html
··· 299 299 position: relative; 300 300 } 301 301 302 - table { 303 - width: 100%; 304 - border-collapse: collapse; 305 - background: var(--input-bg); 306 - border: 0.0625rem solid var(--border-color); 307 - border-radius: 0.25rem; 308 - overflow: hidden; 309 - } 310 - 311 - th, 312 - td { 313 - padding: 0.75rem 0.875rem; 314 - text-align: left; 315 - border-bottom: 0.0625rem solid var(--border-color); 316 - font-size: 0.8125rem; 317 - } 318 - 319 - th { 320 - background: #252620; 321 - color: var(--accent-primary); 322 - font-weight: 700; 323 - text-transform: uppercase; 324 - font-size: 0.6875rem; 325 - letter-spacing: 0.03125rem; 326 - } 327 - 328 - tr:last-child td { 329 - border-bottom: none; 330 - } 331 - 332 - tr:hover { 333 - background: #2d2e28; 334 - } 335 - 336 302 .url-cell { 337 - max-width: 25rem; 303 + max-width: 100%; 338 304 overflow: hidden; 339 305 text-overflow: ellipsis; 340 306 white-space: nowrap; 341 307 color: var(--text-primary); 342 - } 343 - 344 - .short-cell { 345 - color: var(--accent-bright); 346 - font-weight: 700; 347 - } 348 - 349 - .short-cell a { 350 - color: var(--accent-bright); 351 - text-decoration: none; 352 - } 353 - 354 - .short-cell a:hover { 355 - text-decoration: underline; 356 - } 357 - 358 - .date-cell { 359 - color: var(--text-muted); 360 - font-size: 0.75rem; 361 - } 362 - 363 - .actions-cell { 364 - text-align: right; 365 308 } 366 309 367 310 .btn-small { ··· 657 600 const totalHeight = urls.length * ROW_HEIGHT; 658 601 urlsTable.innerHTML = ''; 659 602 660 - const viewport = document.createElement('div'); 661 - viewport.style.height = `${totalHeight}px`; 662 - viewport.style.position = 'relative'; 603 + // Create container with sticky header 604 + const container = document.createElement('div'); 605 + container.style.position = 'relative'; 606 + container.style.height = '100%'; 663 607 664 - const table = document.createElement('table'); 665 - table.style.width = '100%'; 666 - table.style.borderCollapse = 'collapse'; 667 - table.style.tableLayout = 'fixed'; 668 - 669 - const thead = document.createElement('thead'); 670 - thead.style.position = 'sticky'; 671 - thead.style.top = '0'; 672 - thead.style.background = 'var(--input-bg)'; 673 - thead.style.zIndex = '10'; 674 - thead.innerHTML = ` 675 - <tr> 676 - <th style="padding: 0.75rem 0.875rem; text-align: left; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem; width: 15%;">short</th> 677 - <th style="padding: 0.75rem 0.875rem; text-align: left; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem; width: 50%;">url</th> 678 - <th style="padding: 0.75rem 0.875rem; text-align: left; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem; width: 15%;">created</th> 679 - <th style="padding: 0.75rem 0.875rem; text-align: right; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem; width: 20%;">actions</th> 680 - </tr> 608 + // Create sticky header 609 + const header = document.createElement('div'); 610 + header.style.display = 'grid'; 611 + header.style.gridTemplateColumns = '15% 50% 15% 20%'; 612 + header.style.position = 'sticky'; 613 + header.style.top = '0'; 614 + header.style.background = 'var(--input-bg)'; 615 + header.style.zIndex = '10'; 616 + header.style.borderBottom = '0.0625rem solid var(--border-color)'; 617 + header.innerHTML = ` 618 + <div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">short</div> 619 + <div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">url</div> 620 + <div style="padding: 0.75rem 0.875rem; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">created</div> 621 + <div style="padding: 0.75rem 0.875rem; text-align: right; font-size: 0.6875rem; color: var(--accent-primary); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03125rem;">actions</div> 681 622 `; 682 623 683 - const tbody = document.createElement('tbody'); 684 - table.appendChild(thead); 685 - table.appendChild(tbody); 686 - viewport.appendChild(table); 687 - urlsTable.appendChild(viewport); 624 + // Create scrollable content area 625 + const scrollContent = document.createElement('div'); 626 + scrollContent.style.position = 'relative'; 627 + scrollContent.style.height = `${totalHeight}px`; 628 + 629 + container.appendChild(header); 630 + container.appendChild(scrollContent); 631 + urlsTable.appendChild(container); 632 + 633 + let lastScrollTop = -1; 634 + let isInitialRender = true; 688 635 689 636 function updateVisibleRows() { 690 637 const scrollTop = urlsTable.scrollTop; ··· 694 641 const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer); 695 642 const endIndex = Math.min(urls.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + buffer); 696 643 697 - tbody.innerHTML = ''; 644 + // Only update if scroll position changed significantly (skip this check on initial render) 645 + if (!isInitialRender && Math.abs(scrollTop - lastScrollTop) < ROW_HEIGHT / 2) { 646 + return; 647 + } 648 + isInitialRender = false; 649 + lastScrollTop = scrollTop; 650 + 651 + // Clear existing rows 652 + scrollContent.innerHTML = ''; 698 653 699 654 for (let i = startIndex; i < endIndex; i++) { 700 655 const item = urls[i]; 701 - const row = document.createElement('tr'); 702 - row.style.height = `${ROW_HEIGHT}px`; 656 + 657 + const row = document.createElement('div'); 658 + row.style.display = 'grid'; 659 + row.style.gridTemplateColumns = '15% 50% 15% 20%'; 703 660 row.style.position = 'absolute'; 704 - row.style.top = `${i * ROW_HEIGHT + 41}px`; 661 + row.style.top = `${i * ROW_HEIGHT}px`; 705 662 row.style.left = '0'; 706 663 row.style.right = '0'; 707 - row.style.display = 'table'; 708 - row.style.width = '100%'; 709 - row.style.tableLayout = 'fixed'; 664 + row.style.height = `${ROW_HEIGHT}px`; 665 + row.style.borderBottom = '0.0625rem solid var(--border-color)'; 666 + row.style.transition = 'background 0.15s'; 710 667 row.dataset.short = item.shortCode; 668 + 669 + row.addEventListener('mouseenter', () => { 670 + row.style.background = '#2d2e28'; 671 + }); 672 + row.addEventListener('mouseleave', () => { 673 + row.style.background = ''; 674 + }); 711 675 712 676 row.innerHTML = ` 713 - <td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; width: 15%;"> 677 + <div style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; display: flex; align-items: center;"> 714 678 <a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a> 715 - </td> 716 - <td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.8125rem; max-width: 25rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); width: 50%;" title="${item.url}">${item.url}</td> 717 - <td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); font-size: 0.75rem; color: var(--text-muted); width: 15%;">${new Date(item.created).toLocaleDateString()}</td> 718 - <td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); text-align: right; width: 20%;"> 719 - <button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">edit</button> 720 - <button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">delete</button> 721 - </td> 679 + </div> 680 + <div class="url-cell" style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center;" title="${item.url}">${item.url}</div> 681 + <div style="padding: 0.75rem 0.875rem; font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center;">${new Date(item.created).toLocaleDateString()}</div> 682 + <div style="padding: 0.75rem 0.875rem; text-align: right; display: flex; align-items: center; justify-content: flex-end; gap: 0.25rem;"> 683 + <button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">✏️</button> 684 + <button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">🗑️</button> 685 + </div> 722 686 `; 723 687 724 - tbody.appendChild(row); 688 + scrollContent.appendChild(row); 725 689 } 726 690 } 727 691 728 - urlsTable.addEventListener('scroll', updateVisibleRows); 729 - updateVisibleRows(); 692 + urlsTable.addEventListener('scroll', updateVisibleRows, { passive: true }); 693 + 694 + // Use requestAnimationFrame to ensure DOM is ready before initial render 695 + requestAnimationFrame(() => { 696 + updateVisibleRows(); 697 + }); 730 698 } 731 699 732 700 function updateUrlCount() { ··· 823 791 }; 824 792 825 793 window.editUrl = (shortCode, currentUrl) => { 826 - const row = document.querySelector(`tr[data-short="${shortCode}"]`); 794 + const row = document.querySelector(`div[data-short="${shortCode}"]`); 795 + if (!row) return; 796 + 827 797 const urlCell = row.querySelector(".url-cell"); 828 798 829 799 urlCell.innerHTML = ` ··· 871 841 }; 872 842 873 843 window.cancelEdit = (shortCode, originalUrl) => { 874 - const row = document.querySelector(`tr[data-short="${shortCode}"]`); 844 + const row = document.querySelector(`div[data-short="${shortCode}"]`); 845 + if (!row) return; 846 + 875 847 const urlCell = row.querySelector(".url-cell"); 876 848 urlCell.innerHTML = originalUrl; 877 849 urlCell.title = originalUrl;
+4
wrangler.toml
··· 8 8 [observability] 9 9 enabled = true 10 10 11 + [[routes]] 12 + pattern = "hop.dunkirk.sh" 13 + custom_domain = true 14 + 11 15 [[kv_namespaces]] 12 16 binding = "HOP" 13 17 id = "ae7cd39a622b466d876b8410d22d1397"