Files
modelo-fastapi/paginas/index.html
T
dado.cloud 7032db7c92 Modelo do encurtador em FastAPI
Gerado a partir de deploy-templates/fastapi (dado.cloud ee83546).
2026-10-21 16:16:03 +00:00

280 lines
14 KiB
HTML

<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Encurtador de links</title>
<meta name="description" content="Encurte links e acompanhe quantas vezes cada um foi aberto." />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b5bdb'/%3E%3Cpath d='M13.5 18.5l5-5M12 15l-2 2a3.5 3.5 0 005 5l2-2M20 17l2-2a3.5 3.5 0 00-5-5l-2 2' stroke='white' stroke-width='2.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"
/>
<style>
/* Mesmo visual nos modelos Next.js, Express e FastAPI. Mude à vontade. */
:root {
--fundo: #f6f7f9;
--cartao: #ffffff;
--texto: #14161a;
--suave: #5b6270;
--borda: #e3e6eb;
--destaque: #3b5bdb;
--destaque-texto: #ffffff;
--destaque-suave: #edf1ff;
--erro: #c92a2a;
--raio: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--fundo: #0f1115;
--cartao: #171a21;
--texto: #eceef2;
--suave: #9aa1ad;
--borda: #2a2f3a;
--destaque: #8299ff;
--destaque-texto: #0f1115;
--destaque-suave: #1e2540;
--erro: #ff8787;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--fundo);
color: var(--texto);
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
.pagina { max-width: 640px; margin: 0 auto; padding: 56px 16px 32px; }
.topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.marca {
flex: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--destaque); color: var(--destaque-texto);
display: grid; place-items: center;
}
h1 { font-size: 28px; line-height: 1.2; margin: 0; letter-spacing: -0.02em; }
h2 { font-size: 16px; margin: 32px 0 12px; }
.lead { color: var(--suave); margin: 0 0 24px; }
.cartao { background: var(--cartao); border: 1px solid var(--borda); border-radius: var(--raio); padding: 20px; }
.formulario { display: flex; gap: 8px; }
.formulario input {
flex: 1; min-width: 0; font: inherit; padding: 12px 14px;
border: 1px solid var(--borda); border-radius: 10px;
background: var(--fundo); color: var(--texto);
}
.formulario input:focus { outline: 2px solid var(--destaque); outline-offset: 1px; }
button {
font: inherit; font-weight: 600; padding: 12px 20px; border: 0; border-radius: 10px;
background: var(--destaque); color: var(--destaque-texto); cursor: pointer; white-space: nowrap;
}
button:disabled { opacity: 0.6; cursor: progress; }
button:focus-visible, a:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
.secundario { background: var(--cartao); color: var(--destaque); border: 1px solid var(--borda); padding: 8px 14px; }
.erro { color: var(--erro); margin: 10px 0 0; font-size: 14px; }
.resultado {
margin-top: 16px; display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px; background: var(--destaque-suave);
}
.resultado a {
flex: 1; min-width: 0; font-family: var(--mono); font-weight: 600;
color: var(--destaque); text-decoration: none; overflow-wrap: anywhere;
}
.lista { list-style: none; margin: 0; padding: 0; }
.lista li { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-top: 1px solid var(--borda); }
.lista li:first-child { border-top: 0; padding-top: 0; }
.lista li:last-child { padding-bottom: 0; }
.link-info { flex: 1; min-width: 0; }
.link-info a { font-family: var(--mono); font-weight: 600; color: var(--texto); text-decoration: none; overflow-wrap: anywhere; }
.link-info a:hover { color: var(--destaque); }
.destino { display: block; color: var(--suave); font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cliques { font-family: var(--mono); font-size: 14px; color: var(--suave); white-space: nowrap; }
.vazio { color: var(--suave); margin: 0; }
.aviso {
margin: 16px 0 0; font-size: 14px; color: var(--suave);
border: 1px dashed var(--borda); border-radius: var(--raio); padding: 14px 16px;
}
.aviso a { color: var(--destaque); font-weight: 600; }
.sobre { margin-top: 32px; }
.sobre h2 { margin-top: 0; }
.sobre dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 14px; }
.sobre dt { color: var(--suave); }
.sobre dd { margin: 0; overflow-wrap: anywhere; }
.dica { font-size: 13px; color: var(--suave); margin: 14px 0 0; }
code { font-family: var(--mono); font-size: 0.92em; }
.rodape { margin-top: 32px; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; color: var(--suave); }
.rodape a { display: inline-flex; color: var(--texto); }
.rodape svg { display: block; height: 18px; width: auto; }
.so-leitor {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (max-width: 480px) {
.pagina { padding-top: 32px; }
h1 { font-size: 24px; }
.formulario { flex-direction: column; }
.resultado { flex-direction: column; align-items: stretch; }
.sobre dl { grid-template-columns: 1fr; gap: 0; }
.sobre dd { margin-bottom: 8px; }
}
</style>
</head>
<body>
<main class="pagina">
<header class="topo">
<span class="marca" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M10 14l4-4M8.5 11.5l-1.8 1.8a3.2 3.2 0 004.5 4.5l1.8-1.8M15.5 12.5l1.8-1.8a3.2 3.2 0 00-4.5-4.5l-1.8 1.8" />
</svg>
</span>
<h1>Encurtador de links</h1>
</header>
<p class="lead">Cole um link comprido e receba um endereço curto, pronto para compartilhar.</p>
<section class="cartao">
<form id="formulario" class="formulario" novalidate>
<label for="url" class="so-leitor">Link para encurtar</label>
<input id="url" name="url" type="url" inputmode="url" maxlength="2048" placeholder="Cole seu link aqui" autocomplete="off" required />
<button type="submit" id="encurtar">Encurtar</button>
</form>
<p id="erro" class="erro" role="alert" hidden></p>
<div id="resultado" class="resultado" hidden>
<a id="curto" href="#" target="_blank" rel="noopener"></a>
<button type="button" id="copiar" class="secundario">Copiar</button>
</div>
</section>
<h2>Últimos links</h2>
<section class="cartao">
<ul id="lista" class="lista"></ul>
<p id="vazio" class="vazio">Nenhum link ainda. Encurte o primeiro aí em cima.</p>
</section>
<p class="aviso">
Os links ficam na memória do app e somem quando ele é publicado de novo.
Para guardar os links de vez, <a href="https://app.dado.cloud/deploys" target="_blank" rel="noopener">ligue um Postgres</a>.
</p>
<section class="sobre cartao" aria-labelledby="sobre-titulo">
<h2 id="sobre-titulo">Sobre este deploy</h2>
<dl>
<dt>Runtime</dt>
<dd>{{RUNTIME}}</dd>
<dt>Região</dt>
<dd>Rodando em São Paulo</dd>
<dt>No ar desde</dt>
<dd><time datetime="{{INICIO_ISO}}">{{INICIO}}</time></dd>
<dt>Mensagem</dt>
<dd>{{MENSAGEM}}</dd>
</dl>
<p class="dica">
A mensagem vem da variável <code>MENSAGEM_BOAS_VINDAS</code>. Troque o valor nas
configurações do app, no painel, e publique de novo.
</p>
</section>
<footer class="rodape"><span>Publicado na</span><a href="https://dado.cloud" target="_blank" rel="noopener" aria-label="Dado"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192.8 76.0" aria-hidden="true" focusable="false"><path fill="currentColor" d="M22.7 60.4Q17.4 60.4 14.2 56.9Q11 53.3 11 47.2V37.3Q11 31.1 14.2 27.5Q17.4 24 22.7 24Q27 24 29.6 26.4Q32.2 28.8 32.2 32.9L30.7 31.3H32.3L32.1 23.1V13.1H39V59.8H32.2V53.1H30.7L32.2 51.5Q32.2 55.7 29.6 58Q27 60.4 22.7 60.4ZM25.1 54.4Q28.3 54.4 30.2 52.4Q32.1 50.4 32.1 46.8V37.6Q32.1 34 30.2 32Q28.3 30 25.1 30Q21.7 30 19.8 31.9Q17.9 33.9 17.9 37.6V46.8Q17.9 50.5 19.8 52.5Q21.7 54.4 25.1 54.4ZM58.8 60.4Q53.3 60.4 50.1 57.6Q47 54.7 47 49.8Q47 44.8 50.3 42Q53.7 39.1 59.5 39.1H69.3V35.9Q69.3 33 67.5 31.4Q65.8 29.8 62.5 29.8Q59.7 29.8 57.7 31Q55.8 32.3 55.5 34.4H48.7Q49.2 29.6 53.1 26.8Q56.9 24 62.7 24Q69 24 72.6 27.1Q76.3 30.3 76.3 35.8V59.8H69.5V53.4H68.4L69.6 52Q69.6 55.9 66.6 58.2Q63.7 60.4 58.8 60.4ZM60.9 55.1Q64.6 55.1 67 53.3Q69.3 51.4 69.3 48.4V43.8H59.7Q57 43.8 55.5 45.3Q53.9 46.8 53.9 49.3Q53.9 52 55.7 53.6Q57.6 55.1 60.9 55.1ZM96.9 60.4Q91.6 60.4 88.4 56.9Q85.2 53.3 85.2 47.2V37.3Q85.2 31.1 88.4 27.5Q91.6 24 96.9 24Q101.3 24 103.8 26.4Q106.4 28.8 106.4 32.9L105 31.3H106.5L106.3 23.1V13.1H113.3V59.8H106.4V53.1H105L106.4 51.5Q106.4 55.7 103.8 58Q101.3 60.4 96.9 60.4ZM99.3 54.4Q102.6 54.4 104.5 52.4Q106.3 50.4 106.3 46.8V37.6Q106.3 34 104.5 32Q102.6 30 99.3 30Q95.9 30 94 31.9Q92.1 33.9 92.1 37.6V46.8Q92.1 50.5 94 52.5Q95.9 54.4 99.3 54.4ZM136.6 60.4Q132.2 60.4 129 58.7Q125.8 57.1 124.1 54Q122.3 50.9 122.3 46.7V37.7Q122.3 33.5 124.1 30.4Q125.8 27.3 129 25.7Q132.2 24 136.6 24Q140.9 24 144.1 25.7Q147.3 27.3 149 30.4Q150.8 33.5 150.8 37.6V46.7Q150.8 50.9 149 54Q147.3 57.1 144.1 58.7Q140.9 60.4 136.6 60.4ZM136.6 54.3Q140 54.3 142 52.3Q143.9 50.4 143.9 46.7V37.7Q143.9 34 142 32Q140 30.1 136.6 30.1Q133.1 30.1 131.2 32Q129.2 34 129.2 37.7V46.7Q129.2 50.4 131.2 52.3Q133.1 54.3 136.6 54.3Z"/><rect x="159.9" y="9.8" width="26.9" height="56.3" fill="#2E6FF2"/></svg></a></footer>
</main>
<script>
const formulario = document.getElementById('formulario');
const campo = document.getElementById('url');
const botao = document.getElementById('encurtar');
const erro = document.getElementById('erro');
const resultado = document.getElementById('resultado');
const curto = document.getElementById('curto');
const copiar = document.getElementById('copiar');
const lista = document.getElementById('lista');
const vazio = document.getElementById('vazio');
const semProtocolo = (url) => url.replace(/^https?:\/\//, '');
function itemDaLista(link) {
const li = document.createElement('li');
const info = document.createElement('div');
info.className = 'link-info';
const a = document.createElement('a');
a.href = link.curto;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = semProtocolo(link.curto);
const destino = document.createElement('span');
destino.className = 'destino';
destino.textContent = link.url;
destino.title = link.url;
info.append(a, destino);
const cliques = document.createElement('span');
cliques.className = 'cliques';
cliques.textContent = link.cliques === 1 ? '1 clique' : `${link.cliques} cliques`;
li.append(info, cliques);
return li;
}
async function carregarLinks() {
try {
const resposta = await fetch('/api/links', { cache: 'no-store' });
if (!resposta.ok) return;
const { links } = await resposta.json();
lista.replaceChildren(...links.map(itemDaLista));
vazio.hidden = links.length > 0;
} catch {
// Sem rede: a lista fica como estava.
}
}
function mostrarErro(mensagem) {
erro.textContent = mensagem;
erro.hidden = false;
}
formulario.addEventListener('submit', async (evento) => {
evento.preventDefault();
erro.hidden = true;
let url = campo.value.trim();
if (!url) return mostrarErro('Cole um link para encurtar.');
// Quem digita "exemplo.com.br" quer dizer https://exemplo.com.br.
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) url = `https://${url}`;
botao.disabled = true;
try {
const resposta = await fetch('/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const dados = await resposta.json().catch(() => ({}));
if (!resposta.ok) return mostrarErro(dados.erro || 'Não deu para encurtar esse link. Tente de novo.');
curto.href = dados.curto;
curto.textContent = semProtocolo(dados.curto);
copiar.textContent = 'Copiar';
resultado.hidden = false;
campo.value = '';
await carregarLinks();
} catch {
mostrarErro('Sem conexão com o servidor. Tente de novo.');
} finally {
botao.disabled = false;
}
});
copiar.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(curto.href);
copiar.textContent = 'Copiado!';
} catch {
// Navegador sem permissão de área de transferência: seleciona o link.
getSelection().selectAllChildren(curto);
}
});
// Voltou para a aba depois de testar um link? Atualiza a contagem de cliques.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') carregarLinks();
});
carregarLinks();
</script>
</body>
</html>