tangled
alpha
login
or
join now
dunkirk.sh
/
hop
6
fork
atom
blazing fast link redirects on cloudflare kv
hop.dunkirk.sh/u/tacy
6
fork
atom
overview
issues
pulls
pipelines
feat: add virtual scrolling
dunkirk.sh
3 months ago
8bc67f9d
281b0829
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+162
-100
2 changed files
expand all
collapse all
unified
split
src
index.html
index.ts
+134
-97
src/index.html
···
272
272
margin-top: 3rem;
273
273
}
274
274
275
275
+
.table-controls {
276
276
+
display: flex;
277
277
+
gap: 0.5rem;
278
278
+
margin-bottom: 0.75rem;
279
279
+
}
280
280
+
281
281
+
.search-input {
282
282
+
flex: 1;
283
283
+
margin: 0;
284
284
+
}
285
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
291
+
}
292
292
+
293
293
+
.virtual-scroll-container {
294
294
+
height: 37.5rem;
295
295
+
overflow-y: auto;
296
296
+
border: 0.0625rem solid var(--border-color);
297
297
+
border-radius: 0.25rem;
298
298
+
background: var(--input-bg);
299
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
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
443
-
<div id="urlsTable"></div>
464
464
+
<div class="table-controls">
465
465
+
<input type="search" id="searchInput" class="search-input" placeholder="search urls..." />
466
466
+
</div>
467
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
498
+
const searchInput = document.getElementById("searchInput");
474
499
const urlsTable = document.getElementById("urlsTable");
475
475
-
let cachedUrls = [];
500
500
+
501
501
+
let allUrls = [];
502
502
+
let filteredUrls = [];
476
503
let debounceTimer;
477
504
let resultTimeout;
505
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
510
-
const exists = cachedUrls.some((item) => item.shortCode === slug);
538
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
547
+
searchInput.addEventListener("input", () => {
548
548
+
clearTimeout(debounceTimer);
549
549
+
debounceTimer = setTimeout(applySearch, 150);
550
550
+
});
551
551
+
519
552
async function loadUrls() {
520
553
try {
521
521
-
const response = await fetch("/api/urls");
522
522
-
const urls = await response.json();
523
523
-
cachedUrls = urls;
524
524
-
renderUrls();
554
554
+
const response = await fetch("/api/urls?limit=1000");
555
555
+
const data = await response.json();
556
556
+
allUrls = data.urls;
557
557
+
applySearch();
525
558
document.body.classList.remove("loading");
526
559
} catch (error) {
527
527
-
urlsTable.innerHTML = '<div class="empty">failed to load urls</div>';
560
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
532
-
function renderUrls() {
533
533
-
if (cachedUrls.length === 0) {
534
534
-
urlsTable.innerHTML = '<div class="empty">no urls yet</div>';
535
535
-
return;
565
565
+
function applySearch() {
566
566
+
const query = searchInput.value.toLowerCase().trim();
567
567
+
if (!query) {
568
568
+
filteredUrls = [...allUrls];
569
569
+
} else {
570
570
+
filteredUrls = allUrls.filter(item =>
571
571
+
item.shortCode.toLowerCase().includes(query) ||
572
572
+
item.url?.toLowerCase().includes(query)
573
573
+
);
536
574
}
575
575
+
renderVirtualList();
576
576
+
updateUrlCount();
577
577
+
}
537
578
538
538
-
const urls = [...cachedUrls].sort((a, b) => b.created - a.created);
579
579
+
function renderVirtualList() {
580
580
+
const urls = [...filteredUrls].sort((a, b) => b.created - a.created);
539
581
540
540
-
let table = urlsTable.querySelector("table");
541
541
-
if (!table) {
542
542
-
urlsTable.innerHTML = `
543
543
-
<table>
544
544
-
<thead>
545
545
-
<tr>
546
546
-
<th>slug</th>
547
547
-
<th>url</th>
548
548
-
<th>created</th>
549
549
-
<th></th>
550
550
-
</tr>
551
551
-
</thead>
552
552
-
<tbody></tbody>
553
553
-
</table>
554
554
-
`;
555
555
-
table = urlsTable.querySelector("table");
582
582
+
if (urls.length === 0) {
583
583
+
urlsTable.innerHTML = '<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">no urls found</div>';
584
584
+
return;
556
585
}
557
586
558
558
-
const tbody = table.querySelector("tbody");
559
559
-
const existingRows = new Map();
560
560
-
tbody.querySelectorAll("tr[data-short]").forEach((row) => {
561
561
-
existingRows.set(row.dataset.short, row);
562
562
-
});
587
587
+
const totalHeight = urls.length * ROW_HEIGHT;
588
588
+
urlsTable.innerHTML = '';
589
589
+
590
590
+
const viewport = document.createElement('div');
591
591
+
viewport.style.height = `${totalHeight}px`;
592
592
+
viewport.style.position = 'relative';
593
593
+
594
594
+
const table = document.createElement('table');
595
595
+
table.style.width = '100%';
596
596
+
table.style.borderCollapse = 'collapse';
597
597
+
table.style.tableLayout = 'fixed';
598
598
+
599
599
+
const thead = document.createElement('thead');
600
600
+
thead.style.position = 'sticky';
601
601
+
thead.style.top = '0';
602
602
+
thead.style.background = 'var(--input-bg)';
603
603
+
thead.style.zIndex = '10';
604
604
+
thead.innerHTML = `
605
605
+
<tr>
606
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
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
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
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
610
+
</tr>
611
611
+
`;
612
612
+
613
613
+
const tbody = document.createElement('tbody');
614
614
+
table.appendChild(thead);
615
615
+
table.appendChild(tbody);
616
616
+
viewport.appendChild(table);
617
617
+
urlsTable.appendChild(viewport);
563
618
564
564
-
const newRows = new Map();
565
565
-
urls.forEach((item) => {
566
566
-
const existingRow = existingRows.get(item.shortCode);
619
619
+
function updateVisibleRows() {
620
620
+
const scrollTop = urlsTable.scrollTop;
621
621
+
const viewportHeight = urlsTable.clientHeight;
622
622
+
const buffer = 5;
567
623
568
568
-
if (existingRow) {
569
569
-
const urlCell = existingRow.querySelector(".url-cell");
570
570
-
const dateCell = existingRow.querySelector(".date-cell");
624
624
+
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer);
625
625
+
const endIndex = Math.min(urls.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + buffer);
571
626
572
572
-
if (urlCell && urlCell.textContent !== item.url) {
573
573
-
urlCell.textContent = item.url;
574
574
-
urlCell.title = item.url;
575
575
-
}
576
576
-
if (dateCell) {
577
577
-
const dateStr = new Date(item.created).toLocaleDateString();
578
578
-
if (dateCell.textContent !== dateStr) {
579
579
-
dateCell.textContent = dateStr;
580
580
-
}
581
581
-
}
627
627
+
tbody.innerHTML = '';
582
628
583
583
-
newRows.set(item.shortCode, existingRow);
584
584
-
} else {
585
585
-
const row = document.createElement("tr");
629
629
+
for (let i = startIndex; i < endIndex; i++) {
630
630
+
const item = urls[i];
631
631
+
const row = document.createElement('tr');
632
632
+
row.style.height = `${ROW_HEIGHT}px`;
633
633
+
row.style.position = 'absolute';
634
634
+
row.style.top = `${i * ROW_HEIGHT + 41}px`;
635
635
+
row.style.left = '0';
636
636
+
row.style.right = '0';
637
637
+
row.style.display = 'table';
638
638
+
row.style.width = '100%';
639
639
+
row.style.tableLayout = 'fixed';
586
640
row.dataset.short = item.shortCode;
641
641
+
587
642
row.innerHTML = `
588
588
-
<td class="short-cell"><a href="/${item.shortCode}" target="_blank">/${item.shortCode}</a></td>
589
589
-
<td class="url-cell" title="${item.url}">${item.url}</td>
590
590
-
<td class="date-cell">${new Date(item.created).toLocaleDateString()}</td>
591
591
-
<td class="actions-cell">
592
592
-
<button class="btn-small" onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')">edit</button>
593
593
-
<button class="btn-small btn-delete" onclick="deleteUrl('${item.shortCode}')">delete</button>
594
594
-
</td>
595
595
-
`;
596
596
-
newRows.set(item.shortCode, row);
597
597
-
}
598
598
-
});
643
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
644
+
<a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a>
645
645
+
</td>
646
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
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
648
+
<td style="padding: 0.75rem 0.875rem; border-bottom: 0.0625rem solid var(--border-color); text-align: right; width: 20%;">
649
649
+
<button onclick="editUrl('${item.shortCode}', '${item.url.replace(/'/g, "\\'")}')" class="btn-small">edit</button>
650
650
+
<button onclick="deleteUrl('${item.shortCode}')" class="btn-small btn-delete">delete</button>
651
651
+
</td>
652
652
+
`;
599
653
600
600
-
existingRows.forEach((row, shortCode) => {
601
601
-
if (!newRows.has(shortCode)) {
602
602
-
row.remove();
654
654
+
tbody.appendChild(row);
603
655
}
604
604
-
});
656
656
+
}
605
657
606
606
-
tbody.innerHTML = "";
607
607
-
urls.forEach((item) => {
608
608
-
const row = newRows.get(item.shortCode);
609
609
-
if (row) tbody.appendChild(row);
610
610
-
});
611
611
-
612
612
-
sessionStorage.setItem("hop_urls", urlsTable.innerHTML);
613
613
-
updateUrlCount();
658
658
+
urlsTable.addEventListener('scroll', updateVisibleRows);
659
659
+
updateVisibleRows();
614
660
}
615
661
616
662
function updateUrlCount() {
617
617
-
const count = cachedUrls.length;
618
618
-
document.getElementById("urlCount").textContent =
619
619
-
`${count} url${count !== 1 ? "s" : ""}`;
663
663
+
const count = filteredUrls.length;
664
664
+
const total = allUrls.length;
665
665
+
const text = searchInput.value.trim()
666
666
+
? `${count} of ${total} url${total !== 1 ? 's' : ''}`
667
667
+
: `${total} url${total !== 1 ? 's' : ''}`;
668
668
+
document.getElementById("urlCount").textContent = text;
620
669
}
621
670
622
671
loadUrls();
623
623
-
urlInput.addEventListener("focus", () => {
624
624
-
if (cachedUrls.length === 0) loadUrls();
625
625
-
});
626
626
-
627
627
-
function updateLiveTime() {
628
628
-
const now = new Date();
629
629
-
const timeStr = now.toLocaleTimeString("en-US", {hour12: false});
630
630
-
document.getElementById("liveTime").textContent = timeStr;
631
631
-
}
632
632
-
633
633
-
updateLiveTime();
634
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
679
-
cachedUrls.unshift({
716
716
+
allUrls.unshift({
680
717
shortCode: data.shortCode,
681
718
url: url,
682
719
created: Date.now(),
683
720
});
684
684
-
renderUrls();
721
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
758
-
const item = cachedUrls.find((u) => u.shortCode === shortCode);
795
795
+
const item = allUrls.find((u) => u.shortCode === shortCode);
759
796
if (item) item.url = newUrl;
760
760
-
renderUrls();
797
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
786
-
cachedUrls = cachedUrls.filter((u) => u.shortCode !== shortCode);
787
787
-
renderUrls();
823
823
+
allUrls = allUrls.filter((u) => u.shortCode !== shortCode);
824
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
19
-
const list = await env.HOP.list();
20
20
-
const urls = await Promise.all(
19
19
+
const searchParams = url.searchParams;
20
20
+
const limit = parseInt(searchParams.get('limit') || '100');
21
21
+
const cursor = searchParams.get('cursor') || undefined;
22
22
+
const search = searchParams.get('search') || '';
23
23
+
24
24
+
const listOptions: KVNamespaceListOptions = {
25
25
+
limit: Math.min(limit, 1000),
26
26
+
cursor,
27
27
+
};
28
28
+
29
29
+
const list = await env.HOP.list(listOptions);
30
30
+
31
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
27
-
return new Response(JSON.stringify(urls), {
38
38
+
39
39
+
// Filter by search term if provided
40
40
+
if (search) {
41
41
+
const searchLower = search.toLowerCase();
42
42
+
urls = urls.filter(item =>
43
43
+
item.shortCode.toLowerCase().includes(searchLower) ||
44
44
+
item.url?.toLowerCase().includes(searchLower)
45
45
+
);
46
46
+
}
47
47
+
48
48
+
return new Response(JSON.stringify({
49
49
+
urls,
50
50
+
cursor: list.list_complete ? null : list.cursor,
51
51
+
hasMore: !list.list_complete,
52
52
+
}), {
28
53
headers: { 'Content-Type': 'application/json' },
29
54
});
30
55
}