/* ================================================================ URL Shortener — app.js Single-page frontend: shorten URLs, view metadata, admin panel. ================================================================ */ /* --- SVG Icons (Lucide-style) --- */ const IC = { sun: ``, moon: ``, copy: ``, trash: ``, chevL: ``, chevR: ``, inbox: ``, }; /* ================================================================ STATE ================================================================ */ const API_BASE = window.location.pathname.replace(/\/static(\/.*)?$/, '').replace(/\/+$/, '') || ''; let state = { apiKey: localStorage.getItem('urlshort-api-key') || '', isAdmin: false, allUrls: [], currentMeta: null, sortCol: 'created_at', sortDir: 'desc', page: 1, perPage: 20, }; /* ================================================================ THEME ================================================================ */ function getEffectiveTheme() { const m = document.documentElement.dataset.theme; if (m === 'dark' || m === 'light') return m; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function applyTheme(t) { document.documentElement.dataset.theme = t; document.querySelector('meta[name="color-scheme"]').content = t; updateThemeToggle(); } function toggleTheme() { const next = getEffectiveTheme() === 'dark' ? 'light' : 'dark'; localStorage.setItem('urlshort-theme', next); applyTheme(next); } function updateThemeToggle() { const btn = document.getElementById('theme-toggle'); if (!btn) return; const dark = getEffectiveTheme() === 'dark'; btn.innerHTML = dark ? IC.sun : IC.moon; btn.title = dark ? 'Switch to light mode' : 'Switch to dark mode'; } function initTheme() { const saved = localStorage.getItem('urlshort-theme'); if (saved === 'dark' || saved === 'light') applyTheme(saved); else updateThemeToggle(); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!localStorage.getItem('urlshort-theme')) updateThemeToggle(); }); } /* ================================================================ BACKGROUND (dot-grid, same as navpage) ================================================================ */ function mulberry32(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function generateBackground() { const bg = document.getElementById('bg'); if (!bg) return; const W = window.innerWidth, H = window.innerHeight, STEP = 15; const rand = mulberry32(0xC0FFEE42); const cols = Math.ceil(W / STEP) + 2, rows = Math.ceil(H / STEP) + 4; let dots = ''; for (let r = -1; r <= rows; r++) { for (let c = 0; c <= cols; c++) { const x = (c * STEP + (rand() - 0.5) * 3).toFixed(1); const y = (r * STEP + (rand() - 0.5) * 3).toFixed(1); const op = (0.12 + rand() * 0.20).toFixed(3); const rr = (0.5 + rand() * 0.25).toFixed(2); dots += ``; } } bg.innerHTML = ` `; } let _resizeTimer; window.addEventListener('resize', () => { clearTimeout(_resizeTimer); _resizeTimer = setTimeout(generateBackground, 250); }); /* ================================================================ HELPERS ================================================================ */ function esc(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); } function formatDate(ts) { const d = new Date(ts * 1000); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function formatRetention(days) { if (!days || days === 0) return '∞'; if (days === 1) return '1 day'; return `${days} days`; } function isValidUrl(s) { try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } } let _toastTimer; function showToast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.classList.remove('show'), 2000); } /* ================================================================ API ================================================================ */ async function apiGet(path) { const res = await fetch(API_BASE + path); return res; } async function apiPost(path, body) { const res = await fetch(API_BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return res; } async function apiDelete(path) { const res = await fetch(API_BASE + path, { method: 'DELETE' }); return res; } /* ================================================================ URL INPUT ================================================================ */ let _lookupTimer; function setupUrlInput() { const input = document.getElementById('url-input'); input.addEventListener('input', () => { clearTimeout(_lookupTimer); const val = input.value.trim(); console.log('[input event] value:', val); if (!val) { clearResult(); if (state.isAdmin) renderAdminPanel(); return; } // Hide admin panel while typing document.getElementById('admin-area').innerHTML = ''; // Debounced lookup _lookupTimer = setTimeout(() => lookupUrl(val), 350); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { console.log('[keydown] Enter pressed'); e.preventDefault(); handleEnter(); } }); } async function lookupUrl(url) { console.log('[lookupUrl] called with:', url); if (!isValidUrl(url)) { console.log('[lookupUrl] invalid URL, clearing'); clearResult(); return; } try { const res = await apiGet('/api/lookup?url=' + encodeURIComponent(url)); console.log('[lookupUrl] response status:', res.status); if (res.ok) { const data = await res.json(); console.log('[lookupUrl] found existing:', data); state.currentMeta = data; renderMeta(data); setStatus('exists', 'ok'); } else { console.log('[lookupUrl] not found, clearing meta'); state.currentMeta = null; clearResult(); setStatus('', ''); } } catch (e) { console.error('[lookupUrl] error:', e); clearResult(); } } async function handleEnter() { clearTimeout(_lookupTimer); // cancel any pending debounced lookup const input = document.getElementById('url-input'); const url = input.value.trim(); console.log('[handleEnter] url:', url); console.log('[handleEnter] state.apiKey:', state.apiKey ? '(set)' : '(empty)'); console.log('[handleEnter] state.currentMeta:', state.currentMeta); if (!url || !isValidUrl(url)) { console.log('[handleEnter] invalid/empty URL, clearing'); clearResult(); setStatus('', ''); return; } // If the debounced lookup already found this URL exists, just show it if (state.currentMeta && state.currentMeta.original_url === url) { console.log('[handleEnter] URL already found by lookup, showing existing'); setStatus('exists', 'ok'); return; } // Create a new short URL console.log('[handleEnter] proceeding to create...'); setStatus('creating…', ''); try { const res = await apiPost('/api/shorten', { url: url }); console.log('[handleEnter] POST /api/shorten status:', res.status); if (res.status === 201) { const shortUrl = await res.text(); console.log('[handleEnter] created:', shortUrl); const code = shortUrl.split('/').pop(); const metaRes = await apiGet('/api/urls/' + encodeURIComponent(code)); if (metaRes.ok) { const data = await metaRes.json(); state.currentMeta = data; renderMeta(data); setStatus('created ✓', 'ok'); if (state.isAdmin) fetchAdminUrls(); } } else { const err = await res.json().catch(() => null); console.log('[handleEnter] create failed:', res.status, err); setStatus(err?.error || 'error', 'err'); } } catch (e) { console.error('[handleEnter] network error:', e); setStatus('network error', 'err'); } } function setStatus(text, cls) { const el = document.getElementById('url-status'); el.textContent = text; el.className = 'url-status' + (cls ? ' ' + cls : ''); } function clearResult() { state.currentMeta = null; document.getElementById('result-area').innerHTML = ''; } /* ================================================================ METADATA CARD ================================================================ */ function renderMeta(data) { const area = document.getElementById('result-area'); area.innerHTML = `
URL Metadata
Short URL${esc(data.short_url)}
Original URL${esc(data.original_url)}
Short Code${esc(data.short_code)}
Visit Count${data.visit_count}
Created${formatDate(data.created_at)}
Retention${formatRetention(data.retention_days)}
`; } /* ================================================================ ADMIN PANEL ================================================================ */ async function validateApiKey() { if (!state.apiKey) { state.isAdmin = false; document.getElementById('admin-area').innerHTML = ''; return; } try { const res = await apiGet('/api/urls?api_key=' + encodeURIComponent(state.apiKey)); if (res.ok) { const data = await res.json(); state.isAdmin = true; state.allUrls = data.urls || []; const input = document.getElementById('url-input'); if (!input.value.trim()) renderAdminPanel(); } else { state.isAdmin = false; state.allUrls = []; document.getElementById('admin-area').innerHTML = ''; } } catch { state.isAdmin = false; document.getElementById('admin-area').innerHTML = ''; } } async function fetchAdminUrls() { if (!state.isAdmin || !state.apiKey) return; try { const res = await apiGet('/api/urls?api_key=' + encodeURIComponent(state.apiKey)); if (res.ok) { const data = await res.json(); state.allUrls = data.urls || []; const input = document.getElementById('url-input'); if (!input.value.trim()) renderAdminPanel(); } } catch { /* silent */ } } function renderAdminPanel() { const area = document.getElementById('admin-area'); if (!state.isAdmin || state.allUrls.length === 0) { if (state.isAdmin) { area.innerHTML = `
${IC.inbox}

No shortened URLs yet.

`; } else { area.innerHTML = ''; } updateFooter(); return; } const sorted = sortUrls(state.allUrls); const { rows, totalPages, start, end } = paginate(sorted); area.innerHTML = `
All Shortened URLs ${state.allUrls.length} total
${thCol('short_url', 'Short URL', 'col-short')} ${thCol('original_url', 'Original URL', 'col-original')} ${thCol('visit_count', 'Visits', 'col-visits')} ${thCol('created_at', 'Created', 'col-created')} ${thCol('retention_days', 'Retention', 'col-retention')} ${rows.map(renderAdminRow).join('')}
${renderPagination(sorted.length, totalPages, start, end)}
`; updateFooter(); } function thCol(col, label, cls) { const active = state.sortCol === col; const arrow = active ? (state.sortDir === 'asc' ? '▲' : '▼') : '▲'; return ` ${label}${arrow} `; } function renderAdminRow(u) { return ` ${esc(u.short_url.replace(/^https?:\/\/[^/]+/, ''))} ${esc(u.original_url)} ${u.visit_count} ${formatDate(u.created_at)} ${formatRetention(u.retention_days)}
`; } /* ================================================================ SORTING ================================================================ */ function sortUrls(urls) { const col = state.sortCol; const dir = state.sortDir === 'asc' ? 1 : -1; return [...urls].sort((a, b) => { let va = a[col], vb = b[col]; if (col === 'short_url') { va = a.short_url || ''; vb = b.short_url || ''; } if (typeof va === 'string') return va.localeCompare(vb) * dir; return ((va || 0) - (vb || 0)) * dir; }); } function onSort(col) { if (state.sortCol === col) { state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; } else { state.sortCol = col; state.sortDir = col === 'created_at' ? 'desc' : 'asc'; } state.page = 1; renderAdminPanel(); } /* ================================================================ PAGINATION ================================================================ */ function paginate(sorted) { const total = sorted.length; if (state.perPage === 'all' || state.perPage >= total) { return { rows: sorted, totalPages: 1, start: 1, end: total }; } const pp = parseInt(state.perPage, 10); const totalPages = Math.ceil(total / pp); if (state.page > totalPages) state.page = totalPages; if (state.page < 1) state.page = 1; const start = (state.page - 1) * pp; const end = Math.min(start + pp, total); return { rows: sorted.slice(start, end), totalPages, start: start + 1, end }; } function renderPagination(total, totalPages, start, end) { const sizes = [20, 50, 100, 200, 500, 'all']; const sizeOptions = sizes.map(s => `` ).join(''); // Page buttons let pageButtons = ''; if (totalPages > 1) { pageButtons += ``; // Show limited page numbers const maxShow = 5; let pStart = Math.max(1, state.page - Math.floor(maxShow / 2)); let pEnd = Math.min(totalPages, pStart + maxShow - 1); if (pEnd - pStart < maxShow - 1) pStart = Math.max(1, pEnd - maxShow + 1); for (let p = pStart; p <= pEnd; p++) { pageButtons += ``; } pageButtons += ``; } return ` `; } function goPage(p) { state.page = p; renderAdminPanel(); // Scroll to top of admin area document.getElementById('admin-area').scrollIntoView({ behavior: 'smooth', block: 'start' }); } function changePageSize(val) { state.perPage = val === 'all' ? 'all' : parseInt(val, 10); state.page = 1; renderAdminPanel(); } /* ================================================================ ACTIONS ================================================================ */ function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast('Copied to clipboard'); }).catch(() => { // Fallback const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast('Copied to clipboard'); }); } async function deleteUrl(code) { if (!state.apiKey) return; try { const res = await apiDelete('/api/urls/' + encodeURIComponent(code) + '?api_key=' + encodeURIComponent(state.apiKey)); if (res.status === 204) { state.allUrls = state.allUrls.filter(u => u.short_code !== code); showToast('Deleted'); renderAdminPanel(); } else if (res.status === 404) { showToast('Not found'); } else if (res.status === 403) { showToast('Not authorized'); } } catch { showToast('Network error'); } } /* ================================================================ API KEY INPUT ================================================================ */ let _apiKeyTimer; function setupApiKeyInput() { const input = document.getElementById('api-key-input'); // Restore saved key if (state.apiKey) { input.value = state.apiKey; validateApiKey(); } input.addEventListener('input', () => { clearTimeout(_apiKeyTimer); state.apiKey = input.value.trim(); localStorage.setItem('urlshort-api-key', state.apiKey); _apiKeyTimer = setTimeout(() => validateApiKey(), 500); }); } /* ================================================================ FOOTER ================================================================ */ function updateFooter() { const el = document.getElementById('footer-info'); if (!el) return; if (state.isAdmin && state.allUrls.length > 0) { el.textContent = `${state.allUrls.length} shortened URL${state.allUrls.length !== 1 ? 's' : ''}`; } else { el.textContent = 'URL Shortener'; } } /* ================================================================ BOOT ================================================================ */ document.addEventListener('DOMContentLoaded', () => { initTheme(); generateBackground(); setupUrlInput(); setupApiKeyInput(); updateFooter(); });