/* ================================================================
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();
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') {
e.preventDefault();
handleEnter();
}
});
}
async function lookupUrl(url) {
if (!isValidUrl(url)) {
clearResult();
return;
}
try {
const res = await apiGet('/api/lookup?url=' + encodeURIComponent(url));
if (res.ok) {
const data = await res.json();
state.currentMeta = data;
renderMeta(data);
setStatus('exists', 'ok');
} else {
state.currentMeta = null;
clearResult();
setStatus('', '');
}
} catch {
clearResult();
}
}
async function handleEnter() {
clearTimeout(_lookupTimer); // cancel any pending debounced lookup
const input = document.getElementById('url-input');
const url = input.value.trim();
if (!url || !isValidUrl(url)) {
clearResult();
setStatus('', '');
return;
}
// If the debounced lookup already found this URL exists, just show it
if (state.currentMeta && state.currentMeta.original_url === url) {
setStatus('exists', 'ok');
return;
}
// Create a new short URL
setStatus('creating…', '');
try {
const res = await apiPost('/api/shorten', { url: url });
if (res.status === 201) {
const shortUrl = await res.text();
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);
setStatus(err?.error || 'error', 'err');
}
} catch {
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 = `
`;
}
/* ================================================================
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 = `
${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();
});