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

feat: add virtual scrolling

dunkirk.sh 8bc67f9d 281b0829

verified
+162 -100
+134 -97
src/index.html
··· 272 272 margin-top: 3rem; 273 273 } 274 274 275 + .table-controls { 276 + display: flex; 277 + gap: 0.5rem; 278 + margin-bottom: 0.75rem; 279 + } 280 + 281 + .search-input { 282 + flex: 1; 283 + margin: 0; 284 + } 285 + 275 286 .table-header { 276 287 color: var(--text-muted); 277 288 font-size: 0.8125rem; 278 289 margin-bottom: 0.75rem; 279 290 font-weight: 400; 291 + } 292 + 293 + .virtual-scroll-container { 294 + height: 37.5rem; 295 + overflow-y: auto; 296 + border: 0.0625rem solid var(--border-color); 297 + border-radius: 0.25rem; 298 + background: var(--input-bg); 299 + position: relative; 280 300 } 281 301 282 302 table { ··· 406 426 color: var(--text-muted); 407 427 font-size: 0.75rem; 408 428 margin-top: auto; 429 + padding-top: 1.5rem; 409 430 } 410 431 </style> 411 432 </head> ··· 440 461 <h2 class="table-header"> 441 462 <span>// existing urls</span> 442 463 </h2> 443 - <div id="urlsTable"></div> 464 + <div class="table-controls"> 465 + <input type="search" id="searchInput" class="search-input" placeholder="search urls..." /> 466 + </div> 467 + <div id="urlsTable" class="virtual-scroll-container"></div> 444 468 </section> 445 469 </main> 446 470 ··· 471 495 const shortUrlContainer = document.getElementById("shortUrlContainer"); 472 496 const urlInput = document.getElementById("url"); 473 497 const slugInput = document.getElementById("slug"); 498 + const searchInput = document.getElementById("searchInput"); 474 499 const urlsTable = document.getElementById("urlsTable"); 475 - let cachedUrls = []; 500 + 501 + let allUrls = []; 502 + let filteredUrls = []; 476 503 let debounceTimer; 477 504 let resultTimeout; 505 + const ROW_HEIGHT = 60; 478 506 479 507 document.addEventListener("keydown", (e) => { 480 508 if ((e.metaKey || e.ctrlKey) && e.key === "k") { ··· 507 535 return; 508 536 } 509 537 debounceTimer = setTimeout(async () => { 510 - const exists = cachedUrls.some((item) => item.shortCode === slug); 538 + const exists = allUrls.some((item) => item.shortCode === slug); 511 539 if (exists) { 512 540 slugInput.style.borderColor = "var(--error-bg)"; 513 541 } else { ··· 516 544 }, 300); 517 545 }); 518 546 547 + searchInput.addEventListener("input", () => { 548 + clearTimeout(debounceTimer); 549 + debounceTimer = setTimeout(applySearch, 150); 550 + }); 551 + 519 552 async function loadUrls() { 520 553 try { 521 - const response = await fetch("/api/urls"); 522 - const urls = await response.json(); 523 - cachedUrls = urls; 524 - renderUrls(); 554 + const response = await fetch("/api/urls?limit=1000"); 555 + const data = await response.json(); 556 + allUrls = data.urls; 557 + applySearch(); 525 558 document.body.classList.remove("loading"); 526 559 } catch (error) { 527 - urlsTable.innerHTML = '<div class="empty">failed to load urls</div>'; 560 + urlsTable.innerHTML = '<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">failed to load urls</div>'; 528 561 document.body.classList.remove("loading"); 529 562 } 530 563 } 531 564 532 - function renderUrls() { 533 - if (cachedUrls.length === 0) { 534 - urlsTable.innerHTML = '<div class="empty">no urls yet</div>'; 535 - return; 565 + function applySearch() { 566 + const query = searchInput.value.toLowerCase().trim(); 567 + if (!query) { 568 + filteredUrls = [...allUrls]; 569 + } else { 570 + filteredUrls = allUrls.filter(item => 571 + item.shortCode.toLowerCase().includes(query) || 572 + item.url?.toLowerCase().includes(query) 573 + ); 536 574 } 575 + renderVirtualList(); 576 + updateUrlCount(); 577 + } 537 578 538 - const urls = [...cachedUrls].sort((a, b) => b.created - a.created); 579 + function renderVirtualList() { 580 + const urls = [...filteredUrls].sort((a, b) => b.created - a.created); 539 581 540 - let table = urlsTable.querySelector("table"); 541 - if (!table) { 542 - urlsTable.innerHTML = ` 543 - <table> 544 - <thead> 545 - <tr> 546 - <th>slug</th> 547 - <th>url</th> 548 - <th>created</th> 549 - <th></th> 550 - </tr> 551 - </thead> 552 - <tbody></tbody> 553 - </table> 554 - `; 555 - table = urlsTable.querySelector("table"); 582 + if (urls.length === 0) { 583 + urlsTable.innerHTML = '<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">no urls found</div>'; 584 + return; 556 585 } 557 586 558 - const tbody = table.querySelector("tbody"); 559 - const existingRows = new Map(); 560 - tbody.querySelectorAll("tr[data-short]").forEach((row) => { 561 - existingRows.set(row.dataset.short, row); 562 - }); 587 + const totalHeight = urls.length * ROW_HEIGHT; 588 + urlsTable.innerHTML = ''; 589 + 590 + const viewport = document.createElement('div'); 591 + viewport.style.height = `${totalHeight}px`; 592 + viewport.style.position = 'relative'; 593 + 594 + const table = document.createElement('table'); 595 + table.style.width = '100%'; 596 + table.style.borderCollapse = 'collapse'; 597 + table.style.tableLayout = 'fixed'; 598 + 599 + const thead = document.createElement('thead'); 600 + thead.style.position = 'sticky'; 601 + thead.style.top = '0'; 602 + thead.style.background = 'var(--input-bg)'; 603 + thead.style.zIndex = '10'; 604 + thead.innerHTML = ` 605 + <tr> 606 + <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> 607 + <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> 608 + <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> 609 + <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> 610 + </tr> 611 + `; 612 + 613 + const tbody = document.createElement('tbody'); 614 + table.appendChild(thead); 615 + table.appendChild(tbody); 616 + viewport.appendChild(table); 617 + urlsTable.appendChild(viewport); 563 618 564 - const newRows = new Map(); 565 - urls.forEach((item) => { 566 - const existingRow = existingRows.get(item.shortCode); 619 + function updateVisibleRows() { 620 + const scrollTop = urlsTable.scrollTop; 621 + const viewportHeight = urlsTable.clientHeight; 622 + const buffer = 5; 567 623 568 - if (existingRow) { 569 - const urlCell = existingRow.querySelector(".url-cell"); 570 - const dateCell = existingRow.querySelector(".date-cell"); 624 + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer); 625 + const endIndex = Math.min(urls.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + buffer); 571 626 572 - if (urlCell && urlCell.textContent !== item.url) { 573 - urlCell.textContent = item.url; 574 - urlCell.title = item.url; 575 - } 576 - if (dateCell) { 577 - const dateStr = new Date(item.created).toLocaleDateString(); 578 - if (dateCell.textContent !== dateStr) { 579 - dateCell.textContent = dateStr; 580 - } 581 - } 627 + tbody.innerHTML = ''; 582 628 583 - newRows.set(item.shortCode, existingRow); 584 - } else { 585 - const row = document.createElement("tr"); 629 + for (let i = startIndex; i < endIndex; i++) { 630 + const item = urls[i]; 631 + const row = document.createElement('tr'); 632 + row.style.height = `${ROW_HEIGHT}px`; 633 + row.style.position = 'absolute'; 634 + row.style.top = `${i * ROW_HEIGHT + 41}px`; 635 + row.style.left = '0'; 636 + row.style.right = '0'; 637 + row.style.display = 'table'; 638 + row.style.width = '100%'; 639 + row.style.tableLayout = 'fixed'; 586 640 row.dataset.short = item.shortCode; 641 + 587 642 row.innerHTML = ` 588 - <td class="short-cell"><a href="/${item.shortCode}" target="_blank">/${item.shortCode}</a></td> 589 - <td class="url-cell" title="${item.url}">${item.url}</td> 590 - <td class="date-cell">${new Date(item.created).toLocaleDateString()}</td> 591 - <td class="actions-cell"> 592 - <button class="btn-small" onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')">edit</button> 593 - <button class="btn-small btn-delete" onclick="deleteUrl('${item.shortCode}')">delete</button> 594 - </td> 595 - `; 596 - newRows.set(item.shortCode, row); 597 - } 598 - }); 643 + <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%;"> 644 + <a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a> 645 + </td> 646 + <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> 647 + <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> 648 + <td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); text-align: right; width: 20%;"> 649 + <button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">edit</button> 650 + <button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">delete</button> 651 + </td> 652 + `; 599 653 600 - existingRows.forEach((row, shortCode) => { 601 - if (!newRows.has(shortCode)) { 602 - row.remove(); 654 + tbody.appendChild(row); 603 655 } 604 - }); 656 + } 605 657 606 - tbody.innerHTML = ""; 607 - urls.forEach((item) => { 608 - const row = newRows.get(item.shortCode); 609 - if (row) tbody.appendChild(row); 610 - }); 611 - 612 - sessionStorage.setItem("hop_urls", urlsTable.innerHTML); 613 - updateUrlCount(); 658 + urlsTable.addEventListener('scroll', updateVisibleRows); 659 + updateVisibleRows(); 614 660 } 615 661 616 662 function updateUrlCount() { 617 - const count = cachedUrls.length; 618 - document.getElementById("urlCount").textContent = 619 - `${count} url${count !== 1 ? "s" : ""}`; 663 + const count = filteredUrls.length; 664 + const total = allUrls.length; 665 + const text = searchInput.value.trim() 666 + ? `${count} of ${total} url${total !== 1 ? 's' : ''}` 667 + : `${total} url${total !== 1 ? 's' : ''}`; 668 + document.getElementById("urlCount").textContent = text; 620 669 } 621 670 622 671 loadUrls(); 623 - urlInput.addEventListener("focus", () => { 624 - if (cachedUrls.length === 0) loadUrls(); 625 - }); 626 - 627 - function updateLiveTime() { 628 - const now = new Date(); 629 - const timeStr = now.toLocaleTimeString("en-US", {hour12: false}); 630 - document.getElementById("liveTime").textContent = timeStr; 631 - } 632 - 633 - updateLiveTime(); 634 - setInterval(updateLiveTime, 1000); 635 672 636 673 form.addEventListener("submit", async (e) => { 637 674 e.preventDefault(); ··· 676 713 await navigator.clipboard.writeText(shortUrl); 677 714 } catch (err) { } 678 715 679 - cachedUrls.unshift({ 716 + allUrls.unshift({ 680 717 shortCode: data.shortCode, 681 718 url: url, 682 719 created: Date.now(), 683 720 }); 684 - renderUrls(); 721 + applySearch(); 685 722 686 723 form.reset(); 687 724 slugInput.style.borderColor = "var(--border-color)"; ··· 755 792 throw new Error(data.error || "Failed to update URL"); 756 793 } 757 794 758 - const item = cachedUrls.find((u) => u.shortCode === shortCode); 795 + const item = allUrls.find((u) => u.shortCode === shortCode); 759 796 if (item) item.url = newUrl; 760 - renderUrls(); 797 + applySearch(); 761 798 } catch (error) { 762 799 alert(error.message); 763 800 } ··· 783 820 throw new Error(data.error || "Failed to delete URL"); 784 821 } 785 822 786 - cachedUrls = cachedUrls.filter((u) => u.shortCode !== shortCode); 787 - renderUrls(); 823 + allUrls = allUrls.filter((u) => u.shortCode !== shortCode); 824 + applySearch(); 788 825 } catch (error) { 789 826 alert(error.message); 790 827 }
+28 -3
src/index.ts
··· 16 16 } 17 17 18 18 if (url.pathname === '/api/urls' && request.method === 'GET') { 19 - const list = await env.HOP.list(); 20 - const urls = await Promise.all( 19 + const searchParams = url.searchParams; 20 + const limit = parseInt(searchParams.get('limit') || '100'); 21 + const cursor = searchParams.get('cursor') || undefined; 22 + const search = searchParams.get('search') || ''; 23 + 24 + const listOptions: KVNamespaceListOptions = { 25 + limit: Math.min(limit, 1000), 26 + cursor, 27 + }; 28 + 29 + const list = await env.HOP.list(listOptions); 30 + 31 + let urls = await Promise.all( 21 32 list.keys.map(async (key) => ({ 22 33 shortCode: key.name, 23 34 url: await env.HOP.get(key.name), 24 35 created: key.metadata?.created || Date.now(), 25 36 })) 26 37 ); 27 - return new Response(JSON.stringify(urls), { 38 + 39 + // Filter by search term if provided 40 + if (search) { 41 + const searchLower = search.toLowerCase(); 42 + urls = urls.filter(item => 43 + item.shortCode.toLowerCase().includes(searchLower) || 44 + item.url?.toLowerCase().includes(searchLower) 45 + ); 46 + } 47 + 48 + return new Response(JSON.stringify({ 49 + urls, 50 + cursor: list.list_complete ? null : list.cursor, 51 + hasMore: !list.list_complete, 52 + }), { 28 53 headers: { 'Content-Type': 'application/json' }, 29 54 }); 30 55 }