add frontend resources

This commit is contained in:
2026-03-18 02:38:47 +08:00
parent 714981b447
commit 6605224e30
120 changed files with 4964 additions and 0 deletions
+580
View File
@@ -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,'&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();
});