/* 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; })); })();