Files
ushort/static/app.js
T
2026-03-18 02:38:47 +08:00

581 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ================================================================
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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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();
});