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
bug: fix the virtual scrolling
dunkirk.sh
3 months ago
bcdc009b
3373c34d
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+79
-103
2 changed files
expand all
collapse all
unified
split
src
index.html
wrangler.toml
+75
-103
src/index.html
···
299
position: relative;
300
}
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
.url-cell {
337
-
max-width: 25rem;
338
overflow: hidden;
339
text-overflow: ellipsis;
340
white-space: nowrap;
341
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
}
366
367
.btn-small {
···
657
const totalHeight = urls.length * ROW_HEIGHT;
658
urlsTable.innerHTML = '';
659
660
-
const viewport = document.createElement('div');
661
-
viewport.style.height = `${totalHeight}px`;
662
-
viewport.style.position = 'relative';
0
663
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>
681
`;
682
683
-
const tbody = document.createElement('tbody');
684
-
table.appendChild(thead);
685
-
table.appendChild(tbody);
686
-
viewport.appendChild(table);
687
-
urlsTable.appendChild(viewport);
0
0
0
0
0
0
688
689
function updateVisibleRows() {
690
const scrollTop = urlsTable.scrollTop;
···
694
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer);
695
const endIndex = Math.min(urls.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + buffer);
696
697
-
tbody.innerHTML = '';
0
0
0
0
0
0
0
0
698
699
for (let i = startIndex; i < endIndex; i++) {
700
const item = urls[i];
701
-
const row = document.createElement('tr');
702
-
row.style.height = `${ROW_HEIGHT}px`;
0
0
703
row.style.position = 'absolute';
704
-
row.style.top = `${i * ROW_HEIGHT + 41}px`;
705
row.style.left = '0';
706
row.style.right = '0';
707
-
row.style.display = 'table';
708
-
row.style.width = '100%';
709
-
row.style.tableLayout = 'fixed';
710
row.dataset.short = item.shortCode;
0
0
0
0
0
0
0
711
712
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%;">
714
<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>
722
`;
723
724
-
tbody.appendChild(row);
725
}
726
}
727
728
-
urlsTable.addEventListener('scroll', updateVisibleRows);
729
-
updateVisibleRows();
0
0
0
0
730
}
731
732
function updateUrlCount() {
···
823
};
824
825
window.editUrl = (shortCode, currentUrl) => {
826
-
const row = document.querySelector(`tr[data-short="${shortCode}"]`);
0
0
827
const urlCell = row.querySelector(".url-cell");
828
829
urlCell.innerHTML = `
···
871
};
872
873
window.cancelEdit = (shortCode, originalUrl) => {
874
-
const row = document.querySelector(`tr[data-short="${shortCode}"]`);
0
0
875
const urlCell = row.querySelector(".url-cell");
876
urlCell.innerHTML = originalUrl;
877
urlCell.title = originalUrl;
···
299
position: relative;
300
}
301
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
0
0
0
0
0
0
0
0
302
.url-cell {
303
+
max-width: 100%;
304
overflow: hidden;
305
text-overflow: ellipsis;
306
white-space: nowrap;
307
color: var(--text-primary);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
308
}
309
310
.btn-small {
···
600
const totalHeight = urls.length * ROW_HEIGHT;
601
urlsTable.innerHTML = '';
602
603
+
// Create container with sticky header
604
+
const container = document.createElement('div');
605
+
container.style.position = 'relative';
606
+
container.style.height = '100%';
607
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>
0
0
0
622
`;
623
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;
635
636
function updateVisibleRows() {
637
const scrollTop = urlsTable.scrollTop;
···
641
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer);
642
const endIndex = Math.min(urls.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + buffer);
643
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 = '';
653
654
for (let i = startIndex; i < endIndex; i++) {
655
const item = urls[i];
656
+
657
+
const row = document.createElement('div');
658
+
row.style.display = 'grid';
659
+
row.style.gridTemplateColumns = '15% 50% 15% 20%';
660
row.style.position = 'absolute';
661
+
row.style.top = `${i * ROW_HEIGHT}px`;
662
row.style.left = '0';
663
row.style.right = '0';
664
+
row.style.height = `${ROW_HEIGHT}px`;
665
+
row.style.borderBottom = '0.0625rem solid var(--border-color)';
666
+
row.style.transition = 'background 0.15s';
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
+
});
675
676
row.innerHTML = `
677
+
<div style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: var(--accent-bright); font-weight: 700; display: flex; align-items: center;">
678
<a href="/${item.shortCode}" target="_blank" style="color: var(--accent-bright); text-decoration: none;">/${item.shortCode}</a>
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>
686
`;
687
688
+
scrollContent.appendChild(row);
689
}
690
}
691
692
+
urlsTable.addEventListener('scroll', updateVisibleRows, { passive: true });
693
+
694
+
// Use requestAnimationFrame to ensure DOM is ready before initial render
695
+
requestAnimationFrame(() => {
696
+
updateVisibleRows();
697
+
});
698
}
699
700
function updateUrlCount() {
···
791
};
792
793
window.editUrl = (shortCode, currentUrl) => {
794
+
const row = document.querySelector(`div[data-short="${shortCode}"]`);
795
+
if (!row) return;
796
+
797
const urlCell = row.querySelector(".url-cell");
798
799
urlCell.innerHTML = `
···
841
};
842
843
window.cancelEdit = (shortCode, originalUrl) => {
844
+
const row = document.querySelector(`div[data-short="${shortCode}"]`);
845
+
if (!row) return;
846
+
847
const urlCell = row.querySelector(".url-cell");
848
urlCell.innerHTML = originalUrl;
849
urlCell.title = originalUrl;
+4
wrangler.toml
···
8
[observability]
9
enabled = true
10
0
0
0
0
11
[[kv_namespaces]]
12
binding = "HOP"
13
id = "ae7cd39a622b466d876b8410d22d1397"
···
8
[observability]
9
enabled = true
10
11
+
[[routes]]
12
+
pattern = "hop.dunkirk.sh"
13
+
custom_domain = true
14
+
15
[[kv_namespaces]]
16
binding = "HOP"
17
id = "ae7cd39a622b466d876b8410d22d1397"