Files
adlist_mikrotik/static/js/main.js
T
2026-10-09 10:53:10 +02:00

226 lines
9.4 KiB
JavaScript

/* Progressive enhancement for Adlist Converter. No framework and no network
calls while composing a link: only /convert retrieves source data. */
(() => {
'use strict';
const $ = (selector, parent = document) => parent.querySelector(selector);
const $$ = (selector, parent = document) => Array.from(parent.querySelectorAll(selector));
const toastEl = $('#toast');
let toastTimer;
function toast(message, isError = false) {
if (!toastEl) return;
toastEl.textContent = message;
toastEl.classList.toggle('error', isError);
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2600);
}
function setTheme(theme) {
const value = theme === 'light' ? 'light' : 'dark';
document.documentElement.dataset.theme = value;
const themeColor = $('meta[name="theme-color"]');
if (themeColor) themeColor.content = value === 'light' ? '#f4f6f2' : '#131615';
try { localStorage.setItem('adlist-theme', value); } catch (_) {}
}
setTheme(document.documentElement.dataset.theme || 'dark');
function isValidIPv4(ip) {
const octets = ip.split('.');
return octets.length === 4 && octets.every(octet => {
if (!/^(0|[1-9]\d{0,2})$/.test(octet)) return false;
return Number(octet) <= 255;
});
}
function isValidIPv6(ip) {
if (!ip.includes(':') || ip.includes('%') || ip.split('::').length > 2) return false;
let raw = ip;
let ipv4Count = 0;
if (raw.includes('.')) {
const cut = raw.lastIndexOf(':');
if (cut < 0 || !isValidIPv4(raw.slice(cut + 1))) return false;
raw = raw.slice(0, cut) + ':0:0';
ipv4Count = 1;
}
const parts = raw.split(':').filter(Boolean);
if (!parts.every(part => /^[a-f\d]{1,4}$/i.test(part))) return false;
if (raw.includes('::')) return parts.length < 8;
return parts.length === 8 && !raw.startsWith(':') && !raw.endsWith(':');
}
function isValidIP(ip) { return isValidIPv4(ip) || isValidIPv6(ip); }
function normalizeSource(input) {
const value = (input || '').trim();
if (!value || /[\r\n]/.test(value)) return null;
try {
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
const parsed = new URL(candidate);
if (!['https:', 'http:'].includes(parsed.protocol) || !parsed.hostname) return null;
return parsed.toString();
} catch (_) { return null; }
}
function conversionUrl(source, target) {
const origin = /^https?:$/.test(window.location.protocol) ? window.location.origin : 'https://listapp.linuxiarz.pl';
const url = new URL('/convert', origin);
url.searchParams.set('url', source);
url.searchParams.set('ip', target);
return url.href;
}
function setError(input, message) {
if (!input) return;
input.setAttribute('aria-invalid', message ? 'true' : 'false');
const error = $(`#${input.id === 'url-input' ? 'url-error' : 'ip-error'}`);
if (error) { error.textContent = message; error.hidden = !message; }
}
const urlInput = $('#url-input');
const ipInput = $('#ip-input');
const result = $('#generated-link');
const openLink = $('#open-link');
const copyBtn = $('#copy-btn');
const linkState = $('#link-state');
const validationStatus = $('#validation-status');
function updateOutput(showErrors = false) {
if (!urlInput || !ipInput || !result) return;
const rawSource = urlInput.value.trim();
const ip = ipInput.value.trim();
const source = normalizeSource(rawSource);
const ipValid = isValidIP(ip);
if (showErrors || rawSource && !source) setError(urlInput, !source && rawSource ? 'Enter a valid HTTP or HTTPS address.' : '');
if (showErrors || ip && !ipValid) setError(ipInput, !ipValid && ip ? 'Enter a valid IPv4 or IPv6 address.' : '');
$$('.preset').forEach(button => {
const selected = ip === button.dataset.ip;
button.classList.toggle('active', selected);
button.setAttribute('aria-pressed', String(selected));
});
const ready = Boolean(source && ipValid);
const href = ready ? conversionUrl(source, ip) : '';
result.value = href;
if (copyBtn) copyBtn.disabled = !ready;
if (openLink) {
openLink.href = href || '#';
openLink.setAttribute('aria-disabled', String(!ready));
openLink.tabIndex = ready ? 0 : -1;
}
if (linkState) {
linkState.textContent = ready ? 'LINK READY' : 'AWAITING INPUT';
linkState.classList.toggle('live', ready);
}
const status = validationStatus?.parentElement;
if (status && validationStatus) {
status.classList.toggle('ready', ready);
status.classList.toggle('invalid', Boolean(rawSource && !source || ip && !ipValid));
validationStatus.textContent = ready ? 'Endpoint ready to copy' : !rawSource ? 'Waiting for a source URL' : !source ? 'Invalid source URL' : !ipValid ? 'Invalid target address' : 'Waiting for valid input';
}
}
[urlInput, ipInput].forEach(input => {
if (!input) return;
input.addEventListener('input', () => { setError(input, ''); updateOutput(); });
input.addEventListener('blur', () => updateOutput(true));
});
$('#converter-form')?.addEventListener('submit', event => event.preventDefault());
$$('.preset').forEach(button => button.addEventListener('click', () => {
if (!ipInput) return;
ipInput.value = button.dataset.ip || '';
setError(ipInput, '');
updateOutput();
}));
updateOutput();
async function copy(text) {
if (!text) { toast('Nothing to copy', true); return; }
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
} catch (_) {
const temporary = document.createElement('textarea');
temporary.value = text;
temporary.setAttribute('readonly', '');
temporary.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.append(temporary);
temporary.select();
try {
if (!document.execCommand('copy')) throw new Error('Copy rejected');
toast('Copied to clipboard');
} catch (_) {
toast('Copy failed — select the text manually', true);
} finally { temporary.remove(); }
}
}
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-action]');
if (!trigger) return;
const action = trigger.dataset.action;
if (action === 'toggle-theme') {
setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
} else if (action === 'menu') {
const open = document.body.classList.toggle('menu-open');
trigger.setAttribute('aria-expanded', String(open));
} else if (action === 'clear') {
if (!urlInput || !ipInput) return;
urlInput.value = '';
ipInput.value = '195.187.6.34';
setError(urlInput, ''); setError(ipInput, '');
updateOutput();
urlInput.focus();
} else if (action === 'copy') {
const node = $(trigger.dataset.target || '');
void copy(node?.value ?? node?.textContent ?? '');
} else if (action === 'copy-text') {
const value = trigger.dataset.text || '';
const absolute = value.startsWith('/') ? window.location.origin + value : value;
void copy(absolute);
} else if (action === 'use-source') {
if (!urlInput || !ipInput) return;
urlInput.value = trigger.dataset.url || '';
ipInput.value = trigger.dataset.ip || '195.187.6.34';
setError(urlInput, ''); setError(ipInput, '');
updateOutput();
$('#generator')?.scrollIntoView({behavior: 'smooth', block: 'start'});
toast('Source loaded into generator');
} else if (action === 'try-again') {
window.location.reload();
} else if (action === 'show-all') {
const table = $(trigger.dataset.target || '');
if (!table) return;
$$('[data-extra-row]', table).forEach(row => { row.hidden = false; row.removeAttribute('data-extra-row'); });
trigger.closest('.table-footer')?.remove();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && document.body.classList.contains('menu-open')) {
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', event => {
if (!document.body.classList.contains('menu-open')) return;
if (event.target.closest('.sidebar,.menu-toggle')) {
if (event.target.closest('.sidebar a')) {
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
}
return;
}
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
});
$$('[data-filter]').forEach(filter => filter.addEventListener('input', () => {
const table = $(filter.dataset.filter || '');
if (!table) return;
const query = filter.value.trim().toLowerCase();
let visible = 0;
$$('tbody tr:not(.empty-row)', table).forEach(row => {
const match = row.textContent.toLowerCase().includes(query);
const limited = !query && row.hasAttribute('data-extra-row');
row.hidden = !match || limited;
if (!row.hidden) visible++;
});
const empty = $(table.id === 'recent-table' ? '#recent-empty' : `[data-empty-for="#${table.id}"]`);
if (empty) empty.hidden = !query || visible > 0;
}));
})();