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.
119 lines
4.6 KiB
HTML
119 lines
4.6 KiB
HTML
<!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, 3–32 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/<código></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>
|