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
margin-top: 3rem;
273
}
274
0
0
0
0
0
0
0
0
0
0
0
275
.table-header {
276
color: var(--text-muted);
277
font-size: 0.8125rem;
278
margin-bottom: 0.75rem;
279
font-weight: 400;
0
0
0
0
0
0
0
0
0
280
}
281
282
table {
···
406
color: var(--text-muted);
407
font-size: 0.75rem;
408
margin-top: auto;
0
409
}
410
</style>
411
</head>
···
440
<h2 class="table-header">
441
<span>// existing urls</span>
442
</h2>
443
-
<div id="urlsTable"></div>
0
0
0
444
</section>
445
</main>
446
···
471
const shortUrlContainer = document.getElementById("shortUrlContainer");
472
const urlInput = document.getElementById("url");
473
const slugInput = document.getElementById("slug");
0
474
const urlsTable = document.getElementById("urlsTable");
475
-
let cachedUrls = [];
0
0
476
let debounceTimer;
477
let resultTimeout;
0
478
479
document.addEventListener("keydown", (e) => {
480
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
···
507
return;
508
}
509
debounceTimer = setTimeout(async () => {
510
-
const exists = cachedUrls.some((item) => item.shortCode === slug);
511
if (exists) {
512
slugInput.style.borderColor = "var(--error-bg)";
513
} else {
···
516
}, 300);
517
});
518
0
0
0
0
0
519
async function loadUrls() {
520
try {
521
-
const response = await fetch("/api/urls");
522
-
const urls = await response.json();
523
-
cachedUrls = urls;
524
-
renderUrls();
525
document.body.classList.remove("loading");
526
} catch (error) {
527
-
urlsTable.innerHTML = '<div class="empty">failed to load urls</div>';
528
document.body.classList.remove("loading");
529
}
530
}
531
532
-
function renderUrls() {
533
-
if (cachedUrls.length === 0) {
534
-
urlsTable.innerHTML = '<div class="empty">no urls yet</div>';
535
-
return;
0
0
0
0
0
536
}
0
0
0
537
538
-
const urls = [...cachedUrls].sort((a, b) => b.created - a.created);
0
539
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");
556
}
557
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
-
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
563
564
-
const newRows = new Map();
565
-
urls.forEach((item) => {
566
-
const existingRow = existingRows.get(item.shortCode);
0
567
568
-
if (existingRow) {
569
-
const urlCell = existingRow.querySelector(".url-cell");
570
-
const dateCell = existingRow.querySelector(".date-cell");
571
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
-
}
582
583
-
newRows.set(item.shortCode, existingRow);
584
-
} else {
585
-
const row = document.createElement("tr");
0
0
0
0
0
0
0
0
586
row.dataset.short = item.shortCode;
0
587
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
-
});
599
600
-
existingRows.forEach((row, shortCode) => {
601
-
if (!newRows.has(shortCode)) {
602
-
row.remove();
603
}
604
-
});
605
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();
614
}
615
616
function updateUrlCount() {
617
-
const count = cachedUrls.length;
618
-
document.getElementById("urlCount").textContent =
619
-
`${count} url${count !== 1 ? "s" : ""}`;
0
0
0
620
}
621
622
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
636
form.addEventListener("submit", async (e) => {
637
e.preventDefault();
···
676
await navigator.clipboard.writeText(shortUrl);
677
} catch (err) { }
678
679
-
cachedUrls.unshift({
680
shortCode: data.shortCode,
681
url: url,
682
created: Date.now(),
683
});
684
-
renderUrls();
685
686
form.reset();
687
slugInput.style.borderColor = "var(--border-color)";
···
755
throw new Error(data.error || "Failed to update URL");
756
}
757
758
-
const item = cachedUrls.find((u) => u.shortCode === shortCode);
759
if (item) item.url = newUrl;
760
-
renderUrls();
761
} catch (error) {
762
alert(error.message);
763
}
···
783
throw new Error(data.error || "Failed to delete URL");
784
}
785
786
-
cachedUrls = cachedUrls.filter((u) => u.shortCode !== shortCode);
787
-
renderUrls();
788
} catch (error) {
789
alert(error.message);
790
}
···
272
margin-top: 3rem;
273
}
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
+
286
.table-header {
287
color: var(--text-muted);
288
font-size: 0.8125rem;
289
margin-bottom: 0.75rem;
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;
300
}
301
302
table {
···
426
color: var(--text-muted);
427
font-size: 0.75rem;
428
margin-top: auto;
429
+
padding-top: 1.5rem;
430
}
431
</style>
432
</head>
···
461
<h2 class="table-header">
462
<span>// existing urls</span>
463
</h2>
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>
468
</section>
469
</main>
470
···
495
const shortUrlContainer = document.getElementById("shortUrlContainer");
496
const urlInput = document.getElementById("url");
497
const slugInput = document.getElementById("slug");
498
+
const searchInput = document.getElementById("searchInput");
499
const urlsTable = document.getElementById("urlsTable");
500
+
501
+
let allUrls = [];
502
+
let filteredUrls = [];
503
let debounceTimer;
504
let resultTimeout;
505
+
const ROW_HEIGHT = 60;
506
507
document.addEventListener("keydown", (e) => {
508
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
···
535
return;
536
}
537
debounceTimer = setTimeout(async () => {
538
+
const exists = allUrls.some((item) => item.shortCode === slug);
539
if (exists) {
540
slugInput.style.borderColor = "var(--error-bg)";
541
} else {
···
544
}, 300);
545
});
546
547
+
searchInput.addEventListener("input", () => {
548
+
clearTimeout(debounceTimer);
549
+
debounceTimer = setTimeout(applySearch, 150);
550
+
});
551
+
552
async function loadUrls() {
553
try {
554
+
const response = await fetch("/api/urls?limit=1000");
555
+
const data = await response.json();
556
+
allUrls = data.urls;
557
+
applySearch();
558
document.body.classList.remove("loading");
559
} catch (error) {
560
+
urlsTable.innerHTML = '<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">failed to load urls</div>';
561
document.body.classList.remove("loading");
562
}
563
}
564
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
+
);
574
}
575
+
renderVirtualList();
576
+
updateUrlCount();
577
+
}
578
579
+
function renderVirtualList() {
580
+
const urls = [...filteredUrls].sort((a, b) => b.created - a.created);
581
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;
0
0
0
0
0
0
0
0
0
0
0
0
0
585
}
586
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);
618
619
+
function updateVisibleRows() {
620
+
const scrollTop = urlsTable.scrollTop;
621
+
const viewportHeight = urlsTable.clientHeight;
622
+
const buffer = 5;
623
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);
0
626
627
+
tbody.innerHTML = '';
0
0
0
0
0
0
0
0
0
628
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';
640
row.dataset.short = item.shortCode;
641
+
642
row.innerHTML = `
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
+
`;
0
653
654
+
tbody.appendChild(row);
0
0
655
}
656
+
}
657
658
+
urlsTable.addEventListener('scroll', updateVisibleRows);
659
+
updateVisibleRows();
0
0
0
0
0
0
660
}
661
662
function updateUrlCount() {
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;
669
}
670
671
loadUrls();
0
0
0
0
0
0
0
0
0
0
0
0
672
673
form.addEventListener("submit", async (e) => {
674
e.preventDefault();
···
713
await navigator.clipboard.writeText(shortUrl);
714
} catch (err) { }
715
716
+
allUrls.unshift({
717
shortCode: data.shortCode,
718
url: url,
719
created: Date.now(),
720
});
721
+
applySearch();
722
723
form.reset();
724
slugInput.style.borderColor = "var(--border-color)";
···
792
throw new Error(data.error || "Failed to update URL");
793
}
794
795
+
const item = allUrls.find((u) => u.shortCode === shortCode);
796
if (item) item.url = newUrl;
797
+
applySearch();
798
} catch (error) {
799
alert(error.message);
800
}
···
820
throw new Error(data.error || "Failed to delete URL");
821
}
822
823
+
allUrls = allUrls.filter((u) => u.shortCode !== shortCode);
824
+
applySearch();
825
} catch (error) {
826
alert(error.message);
827
}
+28
-3
src/index.ts
···
16
}
17
18
if (url.pathname === '/api/urls' && request.method === 'GET') {
19
-
const list = await env.HOP.list();
20
-
const urls = await Promise.all(
0
0
0
0
0
0
0
0
0
0
0
21
list.keys.map(async (key) => ({
22
shortCode: key.name,
23
url: await env.HOP.get(key.name),
24
created: key.metadata?.created || Date.now(),
25
}))
26
);
27
-
return new Response(JSON.stringify(urls), {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
headers: { 'Content-Type': 'application/json' },
29
});
30
}
···
16
}
17
18
if (url.pathname === '/api/urls' && request.method === 'GET') {
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(
32
list.keys.map(async (key) => ({
33
shortCode: key.name,
34
url: await env.HOP.get(key.name),
35
created: key.metadata?.created || Date.now(),
36
}))
37
);
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
+
}), {
53
headers: { 'Content-Type': 'application/json' },
54
});
55
}