add frontend resources
This commit is contained in:
+580
@@ -0,0 +1,580 @@
|
||||
/* ================================================================
|
||||
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();
|
||||
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() {
|
||||
const input = document.getElementById('url-input');
|
||||
const url = input.value.trim();
|
||||
if (!url || !isValidUrl(url)) {
|
||||
clearResult();
|
||||
setStatus('', '');
|
||||
return;
|
||||
}
|
||||
if (!state.apiKey) {
|
||||
// No API key — just do a lookup
|
||||
await lookupUrl(url);
|
||||
return;
|
||||
}
|
||||
setStatus('creating…', '');
|
||||
try {
|
||||
const res = await apiPost('/api/shorten', { api_key: state.apiKey, url: url });
|
||||
if (res.status === 201) {
|
||||
const shortUrl = await res.text();
|
||||
// Fetch metadata for the newly created URL
|
||||
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');
|
||||
// Refresh admin list
|
||||
if (state.isAdmin) fetchAdminUrls();
|
||||
}
|
||||
} else if (res.status === 403) {
|
||||
setStatus('invalid key', 'err');
|
||||
} 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 = `
|
||||
<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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user