313 lines
12 KiB
HTML
313 lines
12 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ShortLink — URL Shortener</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f172a;
|
||
--surface: #1e293b;
|
||
--border: #334155;
|
||
--accent: #6366f1;
|
||
--accent2: #4f46e5;
|
||
--text: #f1f5f9;
|
||
--muted: #94a3b8;
|
||
--success: #22c55e;
|
||
--error: #ef4444;
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg); color: var(--text); min-height: 100vh;
|
||
}
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||
padding: 1rem 2rem; display: flex; align-items: center; gap: .75rem;
|
||
}
|
||
.logo {
|
||
width: 34px; height: 34px; background: var(--accent); border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; font-size: 1.1rem; color: #fff; flex-shrink: 0;
|
||
}
|
||
header h1 { font-size: 1.4rem; font-weight: 700; }
|
||
header h1 span { color: var(--accent); }
|
||
.tagline { margin-left: auto; color: var(--muted); font-size: .85rem; }
|
||
|
||
/* ── Layout ── */
|
||
main { max-width: 860px; margin: 0 auto; padding: 2rem 1rem; }
|
||
|
||
/* ── Cards ── */
|
||
.card {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem;
|
||
}
|
||
.card-title {
|
||
font-size: .75rem; font-weight: 600; letter-spacing: .08em;
|
||
color: var(--muted); text-transform: uppercase; margin-bottom: 1rem;
|
||
}
|
||
|
||
/* ── Form controls ── */
|
||
.input-row { display: flex; gap: .5rem; }
|
||
input[type="text"] {
|
||
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: .7rem 1rem; color: var(--text);
|
||
font-size: 1rem; outline: none; transition: border-color .15s;
|
||
}
|
||
input[type="text"]:focus { border-color: var(--accent); }
|
||
input::placeholder { color: var(--muted); }
|
||
|
||
.btn {
|
||
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||
padding: .7rem 1.4rem; font-size: 1rem; font-weight: 600; cursor: pointer;
|
||
transition: background .15s; white-space: nowrap;
|
||
}
|
||
.btn:hover { background: var(--accent2); }
|
||
.btn:active { transform: scale(.98); }
|
||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.btn-sm {
|
||
padding: .4rem .9rem; font-size: .85rem; font-weight: 500;
|
||
}
|
||
.btn-ghost {
|
||
background: transparent; color: var(--muted); border: 1px solid var(--border);
|
||
}
|
||
.btn-ghost:hover { background: var(--border); color: var(--text); }
|
||
|
||
.toggle-link {
|
||
display: inline-block; margin-top: .6rem; color: var(--muted);
|
||
font-size: .85rem; cursor: pointer; user-select: none;
|
||
}
|
||
.toggle-link:hover { color: var(--text); }
|
||
|
||
.advanced { margin-top: .75rem; display: none; }
|
||
.advanced label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: .3rem; }
|
||
|
||
/* ── Result ── */
|
||
.result-box {
|
||
margin-top: 1rem; background: var(--bg); border-radius: 8px;
|
||
padding: .9rem 1rem; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
|
||
}
|
||
.result-box a { color: var(--accent); font-weight: 600; word-break: break-all; flex: 1; }
|
||
.result-box a:hover { text-decoration: underline; }
|
||
|
||
.msg {
|
||
margin-top: .6rem; padding: .45rem .9rem; border-radius: 6px; font-size: .88rem;
|
||
}
|
||
.msg.ok { background: rgba(34,197,94,.1); color: var(--success); }
|
||
.msg.err { background: rgba(239,68,68,.1); color: var(--error); }
|
||
|
||
/* ── Stats bar ── */
|
||
.stats-bar {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.pills { display: flex; gap: .75rem; }
|
||
.pill {
|
||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||
padding: .4rem .9rem; text-align: center; font-size: .82rem;
|
||
}
|
||
.pill strong { display: block; font-size: 1.2rem; color: var(--accent); }
|
||
|
||
/* ── Table ── */
|
||
.tbl-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||
th {
|
||
text-align: left; padding: .5rem .75rem; color: var(--muted);
|
||
font-weight: 500; border-bottom: 1px solid var(--border);
|
||
}
|
||
td { padding: .7rem .75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: rgba(99,102,241,.04); }
|
||
|
||
.code-tag {
|
||
background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
|
||
padding: .15rem .45rem; font-family: monospace; font-size: .82rem;
|
||
}
|
||
.url-cell { max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.visits-badge {
|
||
display: inline-block; background: var(--accent); color: #fff;
|
||
border-radius: 99px; padding: .15rem .55rem; font-size: .75rem; font-weight: 600;
|
||
}
|
||
.go-btn {
|
||
color: var(--accent); border: 1px solid var(--accent); border-radius: 4px;
|
||
padding: .2rem .55rem; font-size: .78rem; cursor: pointer; background: transparent;
|
||
transition: background .15s, color .15s;
|
||
}
|
||
.go-btn:hover { background: var(--accent); color: #fff; }
|
||
.del-btn {
|
||
color: var(--error); border: 1px solid var(--error); border-radius: 4px;
|
||
padding: .2rem .55rem; font-size: .78rem; cursor: pointer; background: transparent;
|
||
}
|
||
.del-btn:hover { background: var(--error); color: #fff; }
|
||
|
||
.empty { color: var(--muted); text-align: center; padding: 2.5rem; }
|
||
|
||
/* ── Footer ── */
|
||
footer {
|
||
text-align: center; color: var(--muted); font-size: .8rem;
|
||
padding: 2rem 1rem; border-top: 1px solid var(--border); margin-top: 1rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="logo">S</div>
|
||
<h1>Short<span>Link</span></h1>
|
||
<span class="tagline">Shorten, share & track URLs</span>
|
||
</header>
|
||
|
||
<main>
|
||
|
||
<!-- ── Shorten form ── -->
|
||
<div class="card">
|
||
<p class="card-title">✂ Shorten a URL</p>
|
||
<div class="input-row">
|
||
<input type="text" id="urlInput" placeholder="https://very-long-url.example.com/path/to/page" />
|
||
<button class="btn" onclick="shorten()" id="shortenBtn">Shorten</button>
|
||
</div>
|
||
<span class="toggle-link" onclick="toggleAdvanced()">⚙ Custom code / title</span>
|
||
<div class="advanced" id="advPanel">
|
||
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-top:.5rem">
|
||
<div style="flex:1;min-width:160px">
|
||
<label>Custom short code (optional)</label>
|
||
<input type="text" id="customCode" placeholder="my-link" />
|
||
</div>
|
||
<div style="flex:1;min-width:160px">
|
||
<label>Title / description (optional)</label>
|
||
<input type="text" id="titleInput" placeholder="My awesome page" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="resultArea"></div>
|
||
</div>
|
||
|
||
<!-- ── Analytics ── -->
|
||
<div class="card">
|
||
<div class="stats-bar">
|
||
<p class="card-title" style="margin:0">📊 Analytics</p>
|
||
<button class="btn btn-sm btn-ghost" onclick="loadStats()">↻ Refresh</button>
|
||
</div>
|
||
<div class="pills" id="pills"></div>
|
||
<div class="tbl-wrap" id="tblArea" style="margin-top:1rem"><div class="empty">Loading…</div></div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<footer>ShortLink — Cloud Deployment Assignment · Built with FastAPI + PostgreSQL + Nginx</footer>
|
||
|
||
<script>
|
||
const API = '';
|
||
|
||
async function shorten() {
|
||
const url = document.getElementById('urlInput').value.trim();
|
||
const code = document.getElementById('customCode').value.trim();
|
||
const title = document.getElementById('titleInput').value.trim();
|
||
const out = document.getElementById('resultArea');
|
||
const btn = document.getElementById('shortenBtn');
|
||
|
||
if (!url) { out.innerHTML = '<div class="msg err">Please enter a URL.</div>'; return; }
|
||
|
||
btn.disabled = true; btn.textContent = 'Shortening…';
|
||
|
||
try {
|
||
const resp = await fetch(`${API}/api/shorten`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ url, custom_code: code || undefined, title: title || undefined })
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
out.innerHTML = `<div class="msg err">Error: ${data.detail}</div>`;
|
||
return;
|
||
}
|
||
out.innerHTML = `
|
||
<div class="result-box">
|
||
<a href="${data.short_url}" target="_blank" rel="noopener">${data.short_url}</a>
|
||
<button class="btn btn-sm" onclick="copy('${data.short_url}', this)">Copy</button>
|
||
</div>
|
||
<div class="msg ok">✓ Short link created successfully!</div>`;
|
||
document.getElementById('urlInput').value = '';
|
||
loadStats();
|
||
} catch (e) {
|
||
out.innerHTML = `<div class="msg err">Network error: ${e.message}</div>`;
|
||
} finally {
|
||
btn.disabled = false; btn.textContent = 'Shorten';
|
||
}
|
||
}
|
||
|
||
async function copy(url, btn) {
|
||
await navigator.clipboard.writeText(url);
|
||
btn.textContent = 'Copied!';
|
||
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||
}
|
||
|
||
function toggleAdvanced() {
|
||
const p = document.getElementById('advPanel');
|
||
p.style.display = p.style.display === 'block' ? 'none' : 'block';
|
||
}
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const resp = await fetch(`${API}/api/stats`);
|
||
const data = await resp.json();
|
||
|
||
document.getElementById('pills').innerHTML = `
|
||
<div class="pill"><strong>${data.total_links}</strong>Links</div>
|
||
<div class="pill"><strong>${data.total_visits}</strong>Visits</div>`;
|
||
|
||
if (!data.links.length) {
|
||
document.getElementById('tblArea').innerHTML = '<div class="empty">No links yet. Shorten your first URL above!</div>';
|
||
return;
|
||
}
|
||
|
||
const rows = data.links.map(l => `
|
||
<tr>
|
||
<td><span class="code-tag">${l.code}</span></td>
|
||
<td class="url-cell" title="${l.original_url}">${l.title || l.original_url}</td>
|
||
<td class="url-cell" title="${l.original_url}" style="color:var(--muted);font-size:.8rem">${l.original_url}</td>
|
||
<td><span class="visits-badge">${l.visit_count}</span></td>
|
||
<td style="color:var(--muted);font-size:.78rem">${new Date(l.created_at).toLocaleDateString()}</td>
|
||
<td>
|
||
<button class="go-btn" onclick="window.open('${l.short_url}','_blank')">Open</button>
|
||
<button class="del-btn" onclick="deleteLink('${l.code}')" style="margin-left:4px">Del</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
document.getElementById('tblArea').innerHTML = `
|
||
<table>
|
||
<thead><tr>
|
||
<th>Code</th><th>Title</th><th>Original URL</th>
|
||
<th>Visits</th><th>Created</th><th>Actions</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
} catch (e) {
|
||
document.getElementById('tblArea').innerHTML = '<div class="empty">Could not load statistics.</div>';
|
||
}
|
||
}
|
||
|
||
async function deleteLink(code) {
|
||
const token = prompt('Enter admin token to delete:');
|
||
if (!token) return;
|
||
const resp = await fetch(`${API}/api/links/${code}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
if (resp.ok) loadStats();
|
||
else alert('Delete failed – wrong token or link not found.');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.getElementById('urlInput').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') shorten();
|
||
});
|
||
loadStats();
|
||
setInterval(loadStats, 30000);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|