Crie seu próprio Blog Pessoal usando API do TabNews! (HTML, CSS e JS Puros)
8 meses atrás <---
Github: https://github.com/Jeiel0rbit/jeiel-blog
E aí, comunidade TabNews! 👋
Já pensou em ter um blog pessoal exibindo seus próprios posts publicados aqui no TabNews, mas hospedado onde você quiser e com a sua cara? Hoje vou mostrar como fazer exatamente isso usando apenas HTML, CSS e JavaScript puro, consumindo a API pública do TabNews.
Recentemente, atualizei meu próprio blog (que você pode ver aqui usando essa abordagem, e o resultado é um site leve, rápido e que se atualiza automaticamente sempre que publico algo novo no TabNews.
Vamos ver como fazer? Baseei este tutorial no código que foi desenvolvido a 8 meses atrás! Dessa vez com sugestão do Rafael.
A Mágica por Trás: A API do TabNews
O TabNews oferece uma API REST pública bem simples e poderosa. Para o nosso blog, vamos usar principalmente dois endpoints:
- Listar posts de um usuário:
https://www.tabnews.com.br/api/v1/contents/{username}
- Este endpoint retorna uma lista de conteúdos (posts e comentários) de um usuário específico. Usaremos ele para exibir a lista inicial de posts no nosso blog. Adicionamos
?with_children=false
para não trazer todos os comentários juntos e tornar a resposta mais leve inicialmente.
- Este endpoint retorna uma lista de conteúdos (posts e comentários) de um usuário específico. Usaremos ele para exibir a lista inicial de posts no nosso blog. Adicionamos
- Obter o conteúdo de um post específico:
https://www.tabnews.com.br/api/v1/contents/{username}/{slug}
- Quando o usuário clica em um título na lista, usamos este endpoint para buscar o conteúdo completo daquele post específico, incluindo o corpo em Markdown.
Estrutura Básica (HTML)
Nosso HTML será simples. Usaremos o framework CSS Bulma para facilitar a estilização e Font Awesome para ícones, mas você pode adaptar para o seu framework preferido ou usar CSS puro.
Sei, não é CSS tão puro assim...
Precisamos de:
- Um
<header>
com o título do blog e talvez uma imagem de perfil. - Um
<main>
onde o conteúdo dinâmico será carregado.- Uma
div
para a lista de posts (#posts-list
). - Uma
div
para exibir o detalhe de um post (#post-detail
), inicialmente escondida. - Um campo de busca (
#search-input
). - Um botão "Carregar mais" (
#load-more
).
- Uma
- Um
<footer>
com informações de copyright e links. - Incluir a biblioteca
marked.js
para converter o Markdown do TabNews em HTML:<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
.
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Seu Blog com TabNews</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
/* Adicione os estilos do CSS fornecido ou seus próprios aqui */
pre { position: relative; /* ... outros estilos ... */ }
.copy-button { /* ... estilos ... */ }
img { max-width: 100%; height: auto; }
/* ... mais estilos (dark mode, etc) ... */
</style>
</head>
<body>
<header class="hero is-link is-small mb-5">
<div class="hero-body">
<div class="container">
<div class="level is-mobile">
<div class="level-left">
<div class="level-item">
<figure class="image is-48x48">
<img src="sua-foto.png" alt="Foto de Perfil" class="is-rounded">
</figure>
</div>
<div class="level-item">
<h1 class="title is-2 has-text-white">Meu Blog</h1>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button id="dark-mode-toggle" aria-label="Alternar modo escuro">🌙</button>
</div>
</div>
</div>
</div>
</div>
</header>
<main class="section pt-0">
<div class="container">
<div class="field search-wrapper mb-6">
<div class="control has-icons-left">
<input class="input is-medium" type="text" id="search-input" placeholder="Pesquisar por título...">
<span class="icon is-left"><i class="fas fa-search"></i></span>
</div>
</div>
<div id="posts-list" class="mb-5"></div>
<div class="has-text-centered">
<button id="load-more" class="button is-info is-medium mt-4 is-hidden">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>Carregar mais</span>
</button>
</div>
<div id="post-detail" class="is-hidden">
<button id="back-button" class="button is-link is-light mb-5">
<span class="icon"><i class="fas fa-arrow-left"></i></span>
<span>Voltar para a lista</span>
</button>
<div id="post-content"></div>
</div>
</div>
</main>
<footer class="footer has-background-grey-darker has-text-light mt-6">
<div class="container">
<p class="has-text-centered">© 2025 Seu Nome</p>
</div>
</footer>
<script>
// Nosso JavaScript virá aqui
</script>
</body>
</html>
Buscando e Exibindo Posts (JavaScript)
Agora, a parte principal. Dentro da tag <script>
(ou em um arquivo .js
separado):
-
Variáveis e Inicialização: Pegamos referências aos elementos HTML e definimos algumas variáveis de estado.
document.addEventListener('DOMContentLoaded', () => { const postsList = document.getElementById('posts-list'); const postDetail = document.getElementById('post-detail'); const postContent = document.getElementById('post-content'); const backButton = document.getElementById('back-button'); const loadMoreButton = document.getElementById('load-more'); const searchInput = document.getElementById('search-input'); const searchWrapper = document.querySelector('.search-wrapper'); // Adicione as referências do dark mode se implementado let allFetchedPosts = []; // Guarda todos os posts buscados let posts = []; // Guarda os posts a serem exibidos (pode ser filtrado) let visiblePosts = 5; // Quantos posts exibir inicialmente/por página const POSTS_PER_PAGE = 5; const username = 'JeielMiranda'; // MUDE PARA SEU USUÁRIO TABNEWS! // ... funções virão aqui ... fetchPosts(); // Busca os posts ao carregar a página });
-
Função
WorkspacePosts
: Busca a lista de posts do seu usuário.async function fetchPosts() { try { const response = await fetch(`https://www.tabnews.com.br/api/v1/contents/${username}?with_children=false`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const fetchedPosts = await response.json(); allFetchedPosts = fetchedPosts .filter(post => post.status === 'published' && post.title && post.published_at && !post.parent_id) // Apenas posts publicados, com título, data e que são raiz (não comentários) .sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); // Ordena do mais recente para o mais antigo posts = allFetchedPosts; renderPosts(); } catch (error) { console.error('Erro ao buscar posts:', error); postsList.innerHTML = '<div class="notification is-danger has-text-centered">Erro ao carregar conteúdo.</div>'; } }
- Importante: Filtramos por
status === 'published'
,title
,published_at
e!post.parent_id
para garantir que estamos pegando apenas os posts principais que foram publicados e têm as informações necessárias.
- Importante: Filtramos por
-
Função
renderPosts
: Exibe a lista de posts na tela.function renderPosts(filteredPosts = null) { postsList.innerHTML = ''; // Limpa a lista atual const postsToRender = (filteredPosts !== null ? filteredPosts : posts).slice(0, visiblePosts); postsToRender.forEach(post => { const postElement = document.createElement('article'); postElement.className = 'box post-box mb-5'; // Usando classe 'box' do Bulma postElement.innerHTML = ` <h2 class="title is-4 mb-2"> <a href="#" class="has-text-link post-link" data-slug="${post.slug}">${post.title}</a> </h2> <div class="post-meta level is-mobile mt-3"> <div class="level-left"> <div class="level-item"> <span class="icon-text"> <span class="icon"><i class="fas fa-calendar-alt"></i></span> <span>${new Date(post.published_at).toLocaleDateString()}</span> </span> </div> </div> <div class="level-right"> <div class="level-item mr-3"> <span class="icon-text"> <span class="icon"><i class="fas fa-star"></i></span> <span>${post.tabcoins ?? 0}</span> </span> </div> <div class="level-item"> <span class="icon-text"> <span class="icon"><i class="fas fa-comments"></i></span> <span>${post.children_deep_count ?? 0}</span> </span> </div> </div> </div> `; postsList.appendChild(postElement); }); // Lógica do botão "Carregar Mais" const sourceArray = filteredPosts !== null ? filteredPosts : posts; if (sourceArray.length > visiblePosts) { loadMoreButton.classList.remove('is-hidden'); } else { loadMoreButton.classList.add('is-hidden'); } // Adiciona evento de clique aos links dos posts document.querySelectorAll('.post-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const slug = e.target.getAttribute('data-slug'); showPostDetail(slug); }); }); }
-
Função
showPostDetail
: Busca e exibe o conteúdo de um post específico.async function showPostDetail(slug) { try { const response = await fetch(`https://www.tabnews.com.br/api/v1/contents/${username}/${slug}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const post = await response.json(); if (!post || !post.body) { postContent.innerHTML = '<div class="notification is-warning">Conteúdo não disponível.</div>'; return; } // Usa marked.js para converter Markdown em HTML const contentHtml = marked.parse(post.body); postContent.innerHTML = ` <div class="box content"> <h1 class="title is-2 mb-3">${post.title}</h1> <div class="post-meta level is-mobile is-size-6 has-text-grey mb-5"> <div class="level-left"> <div class="level-item"> <span class="icon-text"> <span class="icon"><i class="fas fa-calendar-alt"></i></span> <span>${new Date(post.published_at).toLocaleDateString()}</span> </span> </div> </div> <div class="level-right"> <div class="level-item mr-3"> <span class="icon-text"> <span class="icon"><i class="fas fa-star"></i></span> <span>${post.tabcoins ?? 0} TabCoins</span> </span> </div> <div class="level-item"> <span class="icon-text"> <span class="icon"><i class="fas fa-comments"></i></span> <span>${post.children_deep_count ?? 0} Comentários</span> </span> </div> </div> </div> <hr class="mb-5"> <div>${contentHtml}</div> <hr class="mt-5"> <a href="https://www.tabnews.com.br/${username}/${post.slug}" class="button is-link is-outlined mt-4" target="_blank" rel="noopener noreferrer"> <span class="icon"><i class="fas fa-external-link-alt"></i></span> <span>Ver no TabNews</span> </a> </div> `; // Adiciona botão de copiar aos blocos de código (opcional, mas útil) addCopyButtons(); // Esconde a lista e mostra o detalhe postsList.classList.add('is-hidden'); loadMoreButton.parentElement.classList.add('is-hidden'); searchWrapper.classList.add('is-hidden'); postDetail.classList.remove('is-hidden'); window.scrollTo(0, 0); // Rola para o topo } catch (error) { console.error('Erro ao buscar post:', error); postContent.innerHTML = '<div class="notification is-danger">Erro ao carregar este post.</div>'; } }
-
Funcionalidades Extras:
- Botão Voltar: Simplesmente esconde a
div
de detalhe e mostra a lista novamente. - Carregar Mais: Incrementa
visiblePosts
e chamarenderPosts
novamente. - Busca: Filtra o array
allFetchedPosts
pelo título e chamarenderPosts
com o array filtrado. - Dark Mode: Adiciona/remove uma classe no
<html>
e salva a preferência nolocalStorage
. - Botão Copiar Código: Uma função
addCopyButtons
que encontrapre code
e adiciona um botão para copiar o conteúdo usandonavigator.clipboard.writeText
.
// Exemplo Botão Voltar backButton.addEventListener('click', () => { postDetail.classList.add('is-hidden'); postsList.classList.remove('is-hidden'); loadMoreButton.parentElement.classList.remove('is-hidden'); searchWrapper.classList.remove('is-hidden'); // Re-renderiza a lista com base na busca atual ou todos os posts const currentQuery = searchInput.value.toLowerCase(); if (currentQuery) { const filtered = allFetchedPosts.filter(p => p.title && p.title.toLowerCase().includes(currentQuery)); renderPosts(filtered); } else { renderPosts(allFetchedPosts); } }); // Exemplo Carregar Mais loadMoreButton.addEventListener('click', () => { visiblePosts += POSTS_PER_PAGE; // Re-renderiza considerando a busca atual const currentQuery = searchInput.value.toLowerCase(); if (currentQuery) { const filtered = allFetchedPosts.filter(p => p.title && p.title.toLowerCase().includes(currentQuery)); renderPosts(filtered); } else { renderPosts(allFetchedPosts); } }); // Exemplo Busca searchInput.addEventListener('input', () => { const query = searchInput.value.toLowerCase(); visiblePosts = POSTS_PER_PAGE; // Reseta paginação na busca const filteredPosts = allFetchedPosts.filter(post => post.title && post.title.toLowerCase().includes(query) ); posts = filteredPosts; // Atualiza 'posts' para o filtro atual renderPosts(filteredPosts); if (!query) { posts = allFetchedPosts; // Reseta se a busca for limpa renderPosts(allFetchedPosts); } }); // Exemplo Botão Copiar (simplificado) function addCopyButtons() { document.querySelectorAll('#post-content pre code').forEach((block) => { const preElement = block.parentElement; if (preElement && preElement.tagName === 'PRE') { preElement.style.position = 'relative'; const button = document.createElement('button'); button.className = 'copy-button'; // Estilize esta classe button.textContent = 'Copiar'; button.onclick = () => { /* ... lógica de cópia ... */ }; preElement.appendChild(button); } }); }
- Botão Voltar: Simplesmente esconde a
Hospedagem
A beleza disso é que, por ser apenas HTML, CSS e JS estáticos, você pode hospedar gratuitamente em serviços como:
- Cloudflare Pages
- GitHub Pages - Onde o meu está hospedado
- Vercel
- Netlify
Conclusão
Com um pouco de código front-end e a API do TabNews, você pode criar um blog pessoal totalmente funcional e integrado com seus conteúdos aqui da plataforma. É uma ótima maneira de ter seu portfólio de posts centralizado e com a sua identidade visual.
O código que usei como base está mais completo (com dark mode e mais detalhes de estilo), mas a lógica central é essa que expliquei. Sinta-se à vontade para adaptar, melhorar e compartilhar suas próprias versões!
Espero que tenham gostado e que seja útil para alguém! Se tiverem dúvidas ou sugestões, comentem aí embaixo!
Até a próxima! 🚀
Promoção: Tem um blog? Queira se ajuntar com devjonatas e sua comunidade: