URL Shrinker: acortador de URLs con Node 24 + node:sqlite

App sin dependencias: UI web, POST /api/shorten (codigo aleatorio o
personalizado), redireccion 302 con contador de clics, /api/stats/<code>
y /healthz. SQLite en DATA_DIR (/data) para montar volumen persistente.
This commit is contained in:
Fable
2026-09-12 03:38:39 +02:00
commit 309661c9e4
5 changed files with 355 additions and 0 deletions

118
index.html Normal file
View File

@@ -0,0 +1,118 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>URL Shrinker</title>
<style>
:root {
--bg: #0f1117; --card: #181b24; --text: #e8eaf0; --muted: #9aa1b2;
--accent: #5b8def; --accent-hover: #7aa2f2; --error: #e06c75; --ok: #7fbf7f;
--border: #2a2f3d; --radius: 10px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8; --card: #ffffff; --text: #1c1f26; --muted: #5c6270;
--accent: #3b6fd4; --accent-hover: #2a5cbf; --error: #c0392b; --ok: #2e7d32;
--border: #dde0e8;
}
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); color: var(--text);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 1.5rem;
}
main { width: 100%; max-width: 34rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h1 span { color: var(--accent); }
p.sub { color: var(--muted); margin: 0 0 1.5rem; }
form {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.25rem; display: grid; gap: .75rem;
}
label { font-size: .85rem; color: var(--muted); display: grid; gap: .3rem; }
input {
width: 100%; padding: .6rem .75rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg); color: var(--text); font-size: 1rem;
}
input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
button {
padding: .65rem; border: 0; border-radius: 6px; background: var(--accent); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: .6; cursor: wait; }
#result {
margin-top: 1rem; padding: 1rem 1.25rem; border-radius: var(--radius); display: none;
background: var(--card); border: 1px solid var(--border); word-break: break-all;
}
#result.ok { display: block; border-color: var(--ok); }
#result.err { display: block; border-color: var(--error); color: var(--error); }
#result a { color: var(--accent); font-weight: 600; text-decoration: none; font-size: 1.1rem; }
#result a:hover { text-decoration: underline; }
#copy {
margin-left: .6rem; padding: .25rem .6rem; font-size: .8rem; font-weight: 500;
background: transparent; color: var(--muted); border: 1px solid var(--border);
}
#copy:hover { color: var(--text); background: transparent; }
footer { margin-top: 1.25rem; text-align: center; color: var(--muted); font-size: .8rem; }
</style>
</head>
<body>
<main>
<h1>URL <span>Shrinker</span></h1>
<p class="sub">Pega una URL larga, llévate una corta.</p>
<form id="form">
<label>URL a acortar
<input id="url" type="url" placeholder="https://ejemplo.com/una/ruta/muy/larga" required autofocus>
</label>
<label>Código personalizado <small>(opcional, 332 caracteres)</small>
<input id="code" type="text" pattern="[A-Za-z0-9_\-]{3,32}" placeholder="mi-enlace">
</label>
<button type="submit" id="submit">Acortar</button>
</form>
<div id="result"></div>
<footer>montlab.dev · los enlaces cuentan sus clics: <code>/api/stats/&lt;código&gt;</code></footer>
</main>
<script>
const form = document.getElementById('form');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit');
btn.disabled = true;
result.className = '';
try {
const body = { url: document.getElementById('url').value };
const code = document.getElementById('code').value.trim();
if (code) body.code = code;
const res = await fetch('/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'error ' + res.status);
result.className = 'ok';
result.innerHTML = '';
const a = document.createElement('a');
a.href = data.short_url; a.textContent = data.short_url; a.target = '_blank'; a.rel = 'noopener';
const copy = document.createElement('button');
copy.id = 'copy'; copy.type = 'button'; copy.textContent = 'Copiar';
copy.onclick = async () => {
await navigator.clipboard.writeText(data.short_url);
copy.textContent = '¡Copiado!';
setTimeout(() => (copy.textContent = 'Copiar'), 1500);
};
result.append(a, copy);
} catch (err) {
result.className = 'err';
result.textContent = err.message;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>