600 lines
22 KiB
JavaScript
600 lines
22 KiB
JavaScript
/* ================================================================
|
||
URL Shortener — app.js
|
||
Single-page frontend: shorten URLs, view metadata, admin panel.
|
||
================================================================ */
|
||
|
||
/* --- SVG Icons (Lucide-style) --- */
|
||
const IC = {
|
||
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
||
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
|
||
copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
|
||
trash: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
|
||
chevL: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
|
||
chevR: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`,
|
||
inbox: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
|
||
};
|
||
|
||
/* ================================================================
|
||
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 += `<circle cx="${x}" cy="${y}" r="${rr}" opacity="${op}"/>`;
|
||
}
|
||
}
|
||
bg.innerHTML = `
|
||
<svg xmlns="http://www.w3.org/2000/svg"
|
||
style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none"
|
||
aria-hidden="true">
|
||
<defs>
|
||
<radialGradient id="top-glow" cx="${(W/2).toFixed(0)}" cy="0"
|
||
r="${(Math.max(W,H)*0.65).toFixed(0)}" gradientUnits="userSpaceOnUse">
|
||
<stop offset="0%" style="stop-color:var(--accent);stop-opacity:0.10"/>
|
||
<stop offset="100%" style="stop-color:var(--accent);stop-opacity:0"/>
|
||
</radialGradient>
|
||
<style>
|
||
@keyframes dot-drift { from{transform:translateY(0)} to{transform:translateY(${STEP}px)} }
|
||
#bg-dots { animation: dot-drift ${STEP*1.6}s linear infinite; }
|
||
</style>
|
||
</defs>
|
||
<g id="bg-dots" style="fill:currentColor">${dots}</g>
|
||
<rect width="100%" height="100%" fill="url(#top-glow)"/>
|
||
</svg>`;
|
||
}
|
||
let _resizeTimer;
|
||
window.addEventListener('resize', () => { clearTimeout(_resizeTimer); _resizeTimer = setTimeout(generateBackground, 250); });
|
||
|
||
/* ================================================================
|
||
HELPERS
|
||
================================================================ */
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,'&').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 = `
|
||
<div class="meta-card">
|
||
<div class="meta-header">
|
||
<span class="meta-title">URL Metadata</span>
|
||
<button class="meta-copy-btn" onclick="copyToClipboard('${esc(data.short_url)}')">
|
||
${IC.copy} Copy short URL
|
||
</button>
|
||
</div>
|
||
<table class="meta-table">
|
||
<tr><th>Short URL</th><td><a href="${esc(data.short_url)}" target="_blank" rel="noopener">${esc(data.short_url)}</a></td></tr>
|
||
<tr><th>Original URL</th><td><a href="${esc(data.original_url)}" target="_blank" rel="noopener">${esc(data.original_url)}</a></td></tr>
|
||
<tr><th>Short Code</th><td>${esc(data.short_code)}</td></tr>
|
||
<tr><th>Visit Count</th><td>${data.visit_count}</td></tr>
|
||
<tr><th>Created</th><td>${formatDate(data.created_at)}</td></tr>
|
||
<tr><th>Retention</th><td>${formatRetention(data.retention_days)}</td></tr>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
/* ================================================================
|
||
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 = `<div class="empty-state">${IC.inbox}<p>No shortened URLs yet.</p></div>`;
|
||
} else {
|
||
area.innerHTML = '';
|
||
}
|
||
updateFooter();
|
||
return;
|
||
}
|
||
|
||
const sorted = sortUrls(state.allUrls);
|
||
const { rows, totalPages, start, end } = paginate(sorted);
|
||
|
||
area.innerHTML = `
|
||
<div class="admin-panel">
|
||
<div class="admin-panel-header">
|
||
<span class="admin-panel-title">All Shortened URLs</span>
|
||
<span class="admin-panel-count">${state.allUrls.length} total</span>
|
||
</div>
|
||
<div class="admin-table-wrap">
|
||
<table class="admin-table">
|
||
<thead>
|
||
<tr>
|
||
${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')}
|
||
<th class="col-actions no-sort"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows.map(renderAdminRow).join('')}
|
||
</tbody>
|
||
</table>
|
||
${renderPagination(sorted.length, totalPages, start, end)}
|
||
</div>
|
||
</div>`;
|
||
updateFooter();
|
||
}
|
||
|
||
function thCol(col, label, cls) {
|
||
const active = state.sortCol === col;
|
||
const arrow = active ? (state.sortDir === 'asc' ? '▲' : '▼') : '▲';
|
||
return `<th class="${cls} ${active ? 'sorted' : ''}"
|
||
onclick="onSort('${col}')">
|
||
${label}<span class="sort-arrow">${arrow}</span>
|
||
</th>`;
|
||
}
|
||
|
||
function renderAdminRow(u) {
|
||
return `
|
||
<tr>
|
||
<td><a href="${esc(u.short_url)}" target="_blank" rel="noopener">${esc(u.short_url.replace(/^https?:\/\/[^/]+/, ''))}</a></td>
|
||
<td><span class="original-url" title="${esc(u.original_url)}">${esc(u.original_url)}</span></td>
|
||
<td class="r">${u.visit_count}</td>
|
||
<td>${formatDate(u.created_at)}</td>
|
||
<td class="r">${formatRetention(u.retention_days)}</td>
|
||
<td class="r">
|
||
<div class="row-actions">
|
||
<button class="btn-icon" title="Copy short URL"
|
||
onclick="event.stopPropagation(); copyToClipboard('${esc(u.short_url)}')">${IC.copy}</button>
|
||
<button class="btn-icon btn-delete" title="Delete"
|
||
onclick="event.stopPropagation(); deleteUrl('${esc(u.short_code)}')">${IC.trash}</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}
|
||
|
||
/* ================================================================
|
||
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 =>
|
||
`<option value="${s}" ${String(state.perPage) === String(s) ? 'selected' : ''}>${s === 'all' ? 'All' : s}</option>`
|
||
).join('');
|
||
|
||
// Page buttons
|
||
let pageButtons = '';
|
||
if (totalPages > 1) {
|
||
pageButtons += `<button class="pagination-btn" ${state.page <= 1 ? 'disabled' : ''} onclick="goPage(${state.page - 1})">${IC.chevL}</button>`;
|
||
// 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 += `<button class="pagination-btn ${p === state.page ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`;
|
||
}
|
||
pageButtons += `<button class="pagination-btn" ${state.page >= totalPages ? 'disabled' : ''} onclick="goPage(${state.page + 1})">${IC.chevR}</button>`;
|
||
}
|
||
|
||
return `
|
||
<div class="pagination">
|
||
<span class="pagination-info">Showing ${start}–${end} of ${total}</span>
|
||
<div class="pagination-controls">
|
||
${pageButtons}
|
||
<select class="page-size-select" onchange="changePageSize(this.value)">
|
||
${sizeOptions}
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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();
|
||
});
|
||
|
||
|