/**
* katproxy — Minimal Frontend Client
*/
// Configuration & State
const DEFAULT_API_URL = 'https://katproxy-api.oiupoyt.space';
const LOCAL_DEV_URL = 'http://localhost:5050';
const apiBase = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? LOCAL_DEV_URL
: DEFAULT_API_URL;
const state = {
protocol: 'all',
aliveOnly: true,
search: '',
sort: 'latency_asc',
page: 1,
limit: 60,
totalPages: 1,
totalProxies: 0,
proxies: [],
timerInterval: null,
nextRefreshSeconds: 0,
refreshTotalDuration: 900 // 15 mins default
};
// DOM Elements
const el = {
statTotal: document.getElementById('stat-total'),
statAlive: document.getElementById('stat-alive'),
statLatency: document.getElementById('stat-latency'),
timerText: document.getElementById('timer-text'),
progressCircle: document.getElementById('progress-circle'),
btnManualRefresh: document.getElementById('btn-manual-refresh'),
protocolFilters: document.getElementById('protocol-filters'),
toggleAlive: document.getElementById('toggle-alive'),
searchInput: document.getElementById('search-input'),
searchClear: document.getElementById('search-clear'),
sortSelect: document.getElementById('sort-select'),
btnCopyAll: document.getElementById('btn-copy-all'),
btnExportTxt: document.getElementById('btn-export-txt'),
resultsCount: document.getElementById('results-count'),
checkingIndicator: document.getElementById('checking-indicator'),
proxyGrid: document.getElementById('proxy-grid'),
emptyState: document.getElementById('empty-state'),
btnResetFilters: document.getElementById('btn-reset-filters'),
paginationBar: document.getElementById('pagination-bar'),
btnPrev: document.getElementById('btn-prev'),
btnNext: document.getElementById('btn-next'),
pageIndicator: document.getElementById('page-indicator'),
linkRawApi: document.getElementById('link-raw-api'),
toastContainer: document.getElementById('toast-container')
};
// SVG Circle circumference: 2 * Math.PI * 6 ≈ 37.7
const CIRCUMFERENCE = 37.7;
/**
* Toast Notification Helper
*/
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `${escapeHtml(message)}`;
el.toastContainer.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 250);
}, 2400);
}
/**
* HTML Escaper
*/
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, (m) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[m]);
}
/**
* Convert 2-letter ISO country code to Emoji Flag
*/
function getCountryFlag(countryCode) {
if (!countryCode || countryCode === 'UN' || countryCode.length !== 2) return '🌐';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(c => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
/**
* Fetch System Statistics
*/
async function loadStats() {
try {
const res = await fetch(`${apiBase}/api/stats`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
el.statTotal.textContent = data.total?.toLocaleString() || '0';
el.statAlive.textContent = data.aliveCount?.toLocaleString() || '0';
el.statLatency.textContent = data.avgLatency ? `${data.avgLatency}ms` : '—';
if (data.isChecking) {
el.checkingIndicator.classList.remove('hidden');
} else {
el.checkingIndicator.classList.add('hidden');
}
if (data.remainingSeconds != null) {
state.nextRefreshSeconds = data.remainingSeconds;
updateTimerDisplay();
}
// Update Raw API link
el.linkRawApi.href = `${apiBase}/api/raw`;
} catch (err) {
console.warn('[katproxy] Could not fetch stats:', err.message);
el.statTotal.textContent = 'Err';
el.statAlive.textContent = 'Err';
}
}
/**
* Update Timer UI
*/
function updateTimerDisplay() {
const s = Math.max(0, state.nextRefreshSeconds);
const minutes = Math.floor(s / 60);
const seconds = s % 60;
el.timerText.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
const ratio = Math.min(1, Math.max(0, s / state.refreshTotalDuration));
const offset = CIRCUMFERENCE - (ratio * CIRCUMFERENCE);
el.progressCircle.style.strokeDashoffset = offset;
}
/**
* Start Local Timer Countdown
*/
function startTimerLoop() {
if (state.timerInterval) clearInterval(state.timerInterval);
state.timerInterval = setInterval(() => {
if (state.nextRefreshSeconds > 0) {
state.nextRefreshSeconds--;
updateTimerDisplay();
} else {
loadStats();
loadProxies();
}
}, 1000);
}
/**
* Load Proxies from Backend
*/
async function loadProxies() {
el.resultsCount.textContent = 'Fetching proxies...';
const params = new URLSearchParams({
page: state.page,
limit: state.limit,
sort: state.sort
});
if (state.protocol !== 'all') {
params.set('protocol', state.protocol);
}
if (state.aliveOnly) {
params.set('alive', 'true');
}
if (state.search.trim()) {
params.set('search', state.search.trim());
}
try {
const res = await fetch(`${apiBase}/api/proxies?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.proxies = data.proxies || [];
state.totalProxies = data.total || 0;
state.totalPages = data.totalPages || 1;
renderProxies();
} catch (err) {
console.error('[katproxy] Load proxies failed:', err);
el.resultsCount.textContent = `Backend connection error (${err.message})`;
el.proxyGrid.innerHTML = '';
el.emptyState.classList.remove('hidden');
el.paginationBar.classList.add('hidden');
}
}
/**
* Render Proxies Grid
*/
function renderProxies() {
el.proxyGrid.innerHTML = '';
if (state.proxies.length === 0) {
el.emptyState.classList.remove('hidden');
el.paginationBar.classList.add('hidden');
el.resultsCount.textContent = '0 matching proxies';
return;
}
el.emptyState.classList.add('hidden');
el.paginationBar.classList.remove('hidden');
el.resultsCount.textContent = `Showing ${(state.page - 1) * state.limit + 1}–${Math.min(state.page * state.limit, state.totalProxies)} of ${state.totalProxies.toLocaleString()} proxies`;
el.pageIndicator.textContent = `Page ${state.page} of ${state.totalPages}`;
el.btnPrev.disabled = state.page <= 1;
el.btnNext.disabled = state.page >= state.totalPages;
const fragment = document.createDocumentFragment();
for (const proxy of state.proxies) {
const card = document.createElement('div');
card.className = 'proxy-card';
// Latency badge class & display
let latencyClass = 'latency-slow';
let latencyText = 'untested';
if (proxy.latency != null) {
if (proxy.latency < 200) latencyClass = 'latency-fast';
else if (proxy.latency < 600) latencyClass = 'latency-med';
latencyText = `${proxy.latency} ms`;
}
// Protocol pills
const protoPills = proxy.protocols.map(p =>
`${p.toUpperCase()}`
).join(' ');
const flag = getCountryFlag(proxy.country);
const countryName = proxy.countryName && proxy.countryName !== 'Unknown'
? proxy.countryName
: (proxy.country || 'Global');
const orgInfo = proxy.org ? escapeHtml(proxy.org) : (proxy.city ? escapeHtml(proxy.city) : 'Public Node');
card.innerHTML = `
${escapeHtml(proxy.host)}
:${proxy.port}
${protoPills}
${latencyText}
${flag}
${escapeHtml(countryName)}
`;
// Click on card body -> copy IP:Port
card.addEventListener('click', (e) => {
if (e.target.closest('.quick-action-link')) return;
copyToClipboard(`${proxy.host}:${proxy.port}`, `Copied ${proxy.host}:${proxy.port}`);
});
// Copy cURL
const btnCurl = card.querySelector('.btn-curl');
btnCurl.addEventListener('click', (e) => {
e.stopPropagation();
const proto = proxy.protocols[0] || 'http';
const cmd = `curl -x ${proto}://${proxy.host}:${proxy.port} https://ifconfig.me`;
copyToClipboard(cmd, `Copied cURL command for ${proxy.host}:${proxy.port}`);
});
// Copy URL
const btnUrl = card.querySelector('.btn-proxy-url');
btnUrl.addEventListener('click', (e) => {
e.stopPropagation();
const proto = proxy.protocols[0] || 'http';
const url = `${proto}://${proxy.host}:${proxy.port}`;
copyToClipboard(url, `Copied ${url}`);
});
fragment.appendChild(card);
}
el.proxyGrid.appendChild(fragment);
}
/**
* Clipboard Copy Helper
*/
async function copyToClipboard(text, successMessage) {
try {
await navigator.clipboard.writeText(text);
showToast(successMessage);
} catch (err) {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(successMessage);
}
}
/**
* Event Handlers Setup
*/
function initEvents() {
// Protocol Filter Pills
el.protocolFilters.addEventListener('click', (e) => {
const btn = e.target.closest('.pill-btn');
if (!btn) return;
el.protocolFilters.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.protocol = btn.dataset.protocol;
state.page = 1;
loadProxies();
});
// Alive Toggle
el.toggleAlive.addEventListener('change', (e) => {
state.aliveOnly = e.target.checked;
state.page = 1;
loadProxies();
});
// Debounced Search Input
let searchTimer = null;
el.searchInput.addEventListener('input', (e) => {
const val = e.target.value;
if (val) {
el.searchClear.classList.remove('hidden');
} else {
el.searchClear.classList.add('hidden');
}
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.search = val;
state.page = 1;
loadProxies();
}, 280);
});
// Search Clear Button
el.searchClear.addEventListener('click', () => {
el.searchInput.value = '';
el.searchClear.classList.add('hidden');
state.search = '';
state.page = 1;
loadProxies();
});
// Sort Select
el.sortSelect.addEventListener('change', (e) => {
state.sort = e.target.value;
state.page = 1;
loadProxies();
});
// Copy Visible Proxies
el.btnCopyAll.addEventListener('click', () => {
if (state.proxies.length === 0) {
showToast('No proxies to copy');
return;
}
const lines = state.proxies.map(p => `${p.host}:${p.port}`).join('\n');
copyToClipboard(lines, `Copied ${state.proxies.length} proxies to clipboard`);
});
// Export TXT
el.btnExportTxt.addEventListener('click', () => {
if (state.proxies.length === 0) {
showToast('No proxies to export');
return;
}
const lines = state.proxies.map(p => `${p.host}:${p.port}`).join('\n');
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `katproxy-${state.protocol}-${Date.now()}.txt`;
link.click();
showToast(`Exported ${state.proxies.length} proxies to file`);
});
// Manual Upstream Refresh Trigger
el.btnManualRefresh.addEventListener('click', async () => {
el.btnManualRefresh.classList.add('spinning');
showToast('Triggering upstream proxy refresh...');
try {
const res = await fetch(`${apiBase}/api/refresh`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(data.message || 'Refresh initiated');
} else {
showToast(data.error || 'Refresh already running');
}
setTimeout(() => {
loadStats();
loadProxies();
el.btnManualRefresh.classList.remove('spinning');
}, 2000);
} catch (err) {
showToast(`Refresh error: ${err.message}`);
el.btnManualRefresh.classList.remove('spinning');
}
});
// Reset Filters Button
el.btnResetFilters.addEventListener('click', () => {
state.protocol = 'all';
state.aliveOnly = false;
state.search = '';
state.page = 1;
el.searchInput.value = '';
el.toggleAlive.checked = false;
el.protocolFilters.querySelectorAll('.pill-btn').forEach(b => {
b.classList.toggle('active', b.dataset.protocol === 'all');
});
loadProxies();
});
// Pagination
el.btnPrev.addEventListener('click', () => {
if (state.page > 1) {
state.page--;
loadProxies();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
el.btnNext.addEventListener('click', () => {
if (state.page < state.totalPages) {
state.page++;
loadProxies();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
}
/**
* Autonomous & Interactive Particle Background (from katdrop)
*/
function initInteractiveParticles() {
const canvas = document.getElementById('particles');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let width = (canvas.width = window.innerWidth);
let height = (canvas.height = window.innerHeight);
window.addEventListener('resize', () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
});
const pointer = {
x: null,
y: null,
radius: 150
};
window.addEventListener('mousemove', (e) => {
pointer.x = e.clientX;
pointer.y = e.clientY;
});
window.addEventListener('mouseleave', () => {
pointer.x = null;
pointer.y = null;
});
window.addEventListener('touchmove', (e) => {
if (e.touches.length > 0) {
pointer.x = e.touches[0].clientX;
pointer.y = e.touches[0].clientY;
}
}, { passive: true });
window.addEventListener('touchend', () => {
pointer.x = null;
pointer.y = null;
});
window.addEventListener('click', (e) => {
for (const p of particles) {
const dx = p.x - e.clientX;
const dy = p.y - e.clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 220 && dist > 0) {
const force = (220 - dist) / 220;
p.disturbVx += (dx / dist) * force * 4;
p.disturbVy += (dy / dist) * force * 4;
}
}
});
const particles = [];
const count = Math.min(80, Math.max(35, Math.floor(width / 20)));
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 0.35 + Math.random() * 0.45;
particles.push({
x: Math.random() * width,
y: Math.random() * height,
baseVx: Math.cos(angle) * speed,
baseVy: Math.sin(angle) * speed,
disturbVx: 0,
disturbVy: 0,
radius: Math.random() * 1.5 + 1,
phase: Math.random() * Math.PI * 2,
twinkleSpeed: 0.0018 + Math.random() * 0.0025
});
}
function animate(time) {
ctx.clearRect(0, 0, width, height);
// Connect particles to each other (constellation network)
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distSq = dx * dx + dy * dy;
if (distSq < 15625) { // 125px
const dist = Math.sqrt(distSq);
ctx.strokeStyle = `rgba(255, 255, 255, ${0.075 * (1 - dist / 125)})`;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
// Update & draw each particle
for (const p of particles) {
const waveX = Math.sin(time * 0.0012 + p.phase) * 0.22;
const waveY = Math.cos(time * 0.0015 + p.phase) * 0.22;
p.x += p.baseVx + p.disturbVx + waveX;
p.y += p.baseVy + p.disturbVy + waveY;
p.disturbVx *= 0.94;
p.disturbVy *= 0.94;
const pad = 15;
if (p.x < -pad) p.x = width + pad;
if (p.x > width + pad) p.x = -pad;
if (p.y < -pad) p.y = height + pad;
if (p.y > height + pad) p.y = -pad;
if (pointer.x !== null && pointer.y !== null) {
const mdx = pointer.x - p.x;
const mdy = pointer.y - p.y;
const mDistSq = mdx * mdx + mdy * mdy;
if (mDistSq < pointer.radius * pointer.radius) {
const mDist = Math.sqrt(mDistSq);
ctx.strokeStyle = `rgba(255, 255, 255, ${0.2 * (1 - mDist / pointer.radius)})`;
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(pointer.x, pointer.y);
ctx.stroke();
if (mDist < 75 && mDist > 0) {
const repel = (75 - mDist) / 75;
p.disturbVx -= (mdx / mDist) * repel * 1.6;
p.disturbVy -= (mdy / mDist) * repel * 1.6;
}
}
}
const alpha = 0.25 + Math.sin(time * p.twinkleSpeed + p.phase) * 0.14;
ctx.fillStyle = `rgba(255, 255, 255, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
// Initialization
document.addEventListener('DOMContentLoaded', () => {
initInteractiveParticles();
initEvents();
loadStats();
loadProxies();
startTimerLoop();
// Periodic stats poll every 12 seconds
setInterval(loadStats, 12000);
});