Parte 2: Crear comentarios en nuestro blog de Svelte 5 y Sveltkit y optimizaciones
En esta segunda parte del tutorial, vamos a meternos de lleno en la creación de los componentes para poder escribir y recuperar los comentarios con Svelte 5 y SvelteKit.
Antes de empezar 🛑 y como siempre, si te has perdido alguno de los tutoriales de esta serie, aquí te dejo todos los tutoriales escritos hasta ahora:
- Crea una Web Fullstack con Svelte 5 y SvelteKit
- Enrutamiento y Cabecera Responsive en SvelteKit: Configuración Inicial para Tu Blog
- Configura PostgreSQL con Drizzle ORM en SvelteKit: Base de datos del blog
- Parte 1: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit
- Parte 2: Cargar posts en la página de inicio y detalle del post con Svelte 5 y Sveltekit
- Autenticación en Svelte 5 y SvelteKit: Crea un Login y Gestión de Sesión con Lucia-Auth
- Página de registro de nuestro blog con Svelte 5 y SvelteKit
- Parte 1: Crear comentarios en nuestro blog de Svelte 5 y Sveltkit y optimizaciones
- Parte 2: Crear comentarios en nuestro blog de Svelte 5 y Sveltkit y optimizaciones 🚩
Modificación de las funciones de posts para recuperar su identificador
Ya que necesitaremos el identificador de un post para poder crear un comentario, abrimos el archivo src\lib\server\db\db-posts.ts
y añadimos las siguientes modificaciones para recuperar también el ID del post:
import { eq, and, type InferSelectModel, desc } from 'drizzle-orm';
import { postsTable, usersTable } from './schema';
import { db } from './db';
export type PostWithUser = Pick<
InferSelectModel<typeof postsTable>,
'id' | 'title' | 'content' | 'createdAt' | 'imageHeader' | 'slugUrl'
> & {
username: string;
avatar: string | null;
};
export async function getPostBySlugUrl(slugUrl: string): Promise<PostWithUser | undefined> {
const result = await db
.select({
id: postsTable.id,
title: postsTable.title,
content: postsTable.content,
createdAt: postsTable.createdAt,
imageHeader: postsTable.imageHeader,
slugUrl: postsTable.slugUrl,
username: usersTable.username,
avatar: usersTable.avatar
})
.from(postsTable)
.innerJoin(usersTable, eq(postsTable.userId, usersTable.id))
.where(and(eq(postsTable.isDraft, false), eq(postsTable.slugUrl, slugUrl)));
return result[0];
}
export async function getPosts(limit: number = 5, offset: number = 0): Promise<PostWithUser[]> {
return await db
.select({
id: postsTable.id,
title: postsTable.title,
content: postsTable.content,
createdAt: postsTable.createdAt,
imageHeader: postsTable.imageHeader,
slugUrl: postsTable.slugUrl,
username: usersTable.username,
avatar: usersTable.avatar
})
.from(postsTable)
.innerJoin(usersTable, eq(postsTable.userId, usersTable.id))
.where(eq(postsTable.isDraft, false))
.orderBy(desc(postsTable.createdAt))
.limit(limit)
.offset(offset);
}
Ahora, tanto en la interfaz como en las dos funciones, también devolveremos el ID.
Retornar el nombre de usuario
Para determinar si un usuario está autenticado y permitirle acceder a la caja de comentarios para escribir, editaremos el archivo src\routes\blog\[slug]\+page.server.ts
para incluir el nombre de usuario en la respuesta. Si este valor está definido, podremos asumir que el usuario ha iniciado sesión.
No te preocupes por intentos de manipulación desde el cliente, ya que en el tutorial anterior nos aseguramos de validar la autenticación del usuario en el servidor al momento de crear un comentario, utilizando la cookie de sesión. La validación que estamos implementando aquí es simplemente una verificación rápida en el cliente. Además, hemos optado por usar el nombre de usuario en lugar de otro campo como el ID, ya que es más representativo para esta funcionalidad.
Ahora, abre el archivo y añade el siguiente código:
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPostBySlugUrl } from '$lib/server/db/db-posts';
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await getPostBySlugUrl(params.slug);
if (post) {
return { post, username: locals.user?.username };
}
error(404, 'Not found');
};
En el archivo src\routes\blog\[slug]\+page.svelte
también se lo tendremos que enviar al componente ArticleDetail
:
...
<ArticleDetail post={data.post} username={data.username}/>
En el componente src\lib\components\Article\ArticleDetail.svelte
realizaremos las siguientes modificaciones para recuperar el username
y enviárselo al componente Comments
junto al ID del post. No te preocupes porque ahora tengas un error porque no existe el componente Comments
, lo crearemos a continuación:
<script lang="ts">
import { ArticleFooter, Comments } from '$components';
import type { PostWithUser } from '$lib/server/db/db-posts';
interface ArticleDetailProps {
post: PostWithUser;
username: string | undefined;
}
let { post, username }: ArticleDetailProps = $props();
</script>
<article class="post">
<div class="image-post">
<img src={`../${post.imageHeader}`} alt="" />
</div>
<div class="title-post">
<h1>{post.title}</h1>
</div>
<div class="separator"></div>
<div class="content-post">
{@html post.content}
</div>
<ArticleFooter
slug={post.slugUrl}
username={post.username}
createdAt={post.createdAt}
avatar={post.avatar}
/>
</article>
<Comments postId={post.id} {username}/>
<style>
.title-post h1 {
margin-top: 1rem;
font-size: 2.7rem;
color: #fe5c71;
}
</style>
Creación del componente Comment
Este componente se encargará de mostrar un comentario en particular. Creamos el archivo src\lib\components\Article\Comment.svelte
(recuerda añadirlo al archivo de exportaciones) y añadimos el siguiente contenido:
<script lang="ts">
import defaultAvatar from '$assets/default-avatar.jpg';
import type { CommentWithUser } from '$lib/server/db/db-comments';
interface CommentProps {
comment: CommentWithUser;
}
let { comment }: CommentProps = $props();
</script>
<div class="comments">
<div class="comment">
<div><img class="avatar" src={comment.avatar || defaultAvatar} alt="Avatar" /></div>
<div class="comment-user-info">
<div><strong>{comment.username}</strong></div>
<div class="comment-date">
{comment.createdAt.toLocaleDateString('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric'
})}
</div>
</div>
</div>
<div class="comment-content">
{comment.content}
</div>
</div>
<style>
.comments {
display: flex;
gap: 1rem;
max-width: 700px;
margin: 0 auto;
margin-top: 1rem;
border: 1px solid #ccc;
border-radius: 0.625em;
padding: 1rem;
}
.comment {
display: flex;
text-align: center;
align-items: center;
padding: 5px;
gap: 0.7rem;
}
.comment .avatar {
width: 35px;
height: 35px;
cursor: pointer;
border-radius: 50%;
object-fit: cover;
}
.comment .comment-user-info {
text-align: left;
display: flex;
flex-direction: column;
font-size: 0.8rem;
width: auto;
white-space: nowrap;
}
.comment-date {
width: 100%;
display: block;
font-size: 0.6rem;
}
.comment-content {
text-align: justify;
gap: 0.5rem;
color: #5a5a5a;
}
</style>
En este componente recibiremos el comentario de tipo CommentWithUser
y después añadiremos el HTML
y CSS
para darle estilos.
Creación del componente Comments
Por último, vamos a crear el componente Comments
el cual se encargará de cargar los comentarios y nos permitirá crear comentarios si estamos autenticados. Para ello, creamos el archivo src\lib\components\Article\Comments.svelte
(recuerda añadirlo al archivo de exportaciones) y añadimos el siguiente contenido:
<script lang="ts">
import { page } from '$app/stores';
import { Button, Snipper } from '$components';
import Popup, { type TypePopup } from '$components/Popup.svelte';
import type { CommentWithUser } from '$lib/server/db/db-comments';
import { onMount } from 'svelte';
import Comment from './Comment.svelte';
import { loadPaginateData } from '$lib/utils/fecth-api-data';
interface CommentsProps {
postId: number;
username?: string;
}
let { postId, username }: CommentsProps = $props();
let comments: CommentWithUser[] = $state([]);
let offset: number = $state(0);
let hasComments: boolean = $state(true);
let isLoading: boolean = $state(false);
let commentText: string = $state('');
let messagePopup: string = $state('');
let messageTypePopup: TypePopup = $state('success');
let isPopupVisible: boolean = $state(false);
let buttonText: string = $state('Enviar');
async function saveComment(event: MouseEvent) {
event.preventDefault();
if (!commentText) {
return;
}
buttonText = 'Guardando...';
const response = await fetch(`${$page.url.origin}/api/posts/${postId}/comments`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: commentText })
});
buttonText = 'Enviar';
if (response.ok) {
commentText = '';
const commentResponse: CommentWithUser = await response.json();
commentResponse.createdAt = new Date(commentResponse.createdAt);
comments = [commentResponse, ...comments];
isPopupVisible = true;
messageTypePopup = 'success';
messagePopup = 'El comentario se ha creado exitosamente';
setTimeout(() => {
isPopupVisible = false;
}, 3000);
} else {
isPopupVisible = true;
messageTypePopup = 'error';
const commentResponse = await response.json();
messagePopup = commentResponse.error;
setTimeout(() => {
isPopupVisible = false;
}, 3000);
}
}
async function loadComments() {
if (!hasComments || isLoading) {
return;
}
isLoading = true;
try {
const response = await loadPaginateData(offset, `${$page.url.origin}/api/posts/${postId}/comments`);
offset += 5;
if (response.status === 200) {
const responseComment = await response.json();
let newComments: CommentWithUser[] = responseComment.comments;
newComments = newComments.map((comment) => {
comment.createdAt = new Date(comment.createdAt);
return comment;
});
if (newComments.length === 0) {
hasComments = false;
} else {
comments = [...comments, ...newComments];
if (newComments.length < 10) {
hasComments = false;
}
}
} else {
const error = await response.json();
console.error('Error:', error.error || 'Unexpected error');
}
} catch (err) {
console.error('Network or server error:', err);
}
isLoading = false;
}
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoading) {
loadComments();
}
},
{ threshold: 1.0 }
);
const target = document.querySelector('#load-more');
if (target) observer.observe(target);
return () => observer.disconnect();
});
</script>
{#if username}
<div class="form-comments">
<div>
<label for="comment">Escribe un comentario:</label>
<textarea name="comment" id="comment" bind:value={commentText}></textarea>
</div>
<Button onclick={saveComment} isDisabled={commentText.trim().length === 0}>{buttonText}</Button>
</div>
{:else}
<div class="message-comments">Debes estar autenticado para poder escribir comentarios</div>
{/if}
<h2 class="comment-title">Comentarios</h2>
{#each comments as comment}
<Comment {comment} />
{/each}
{#if isLoading}
<Snipper />
{/if}
<div id="load-more" class="load-more"></div>
<Popup type={messageTypePopup} visible={isPopupVisible}>{messagePopup}</Popup>
{#if !hasComments}
{#if comments.length > 0}
<div class="message-no-data">No hay más comentarios</div>
{:else}
<div class="message-no-data">No hay comentarios</div>
{/if}
{/if}
<style>
.comment-title {
margin-top: 1rem;
color: #fe5c71;
}
textarea {
font-size: 1.2rem;
width: 100%;
padding: 1rem;
border-radius: 0.7rem;
border: 1px solid #999999;
background-color: #f0f0f0;
margin-bottom: 1rem;
resize: none;
transition: 0.3s;
}
.form-comments {
max-width: 600px;
margin: 0 auto;
}
.form-comments label {
display: block;
width: 100%;
text-align: left;
color: #fe5c71;
}
.message-comments {
color: #fe5c71;
max-width: 100%;
font-size: 1.1rem;
}
.load-more {
height: 1px;
margin-bottom: 1rem;
}
.message-no-data {
width: 100%;
text-align: center;
font-size: 1.2rem;
color: #6c6c6c;
margin-bottom: 1rem;
}
</style>
Este componente se encarga de varias acciones en los comentarios de un post. Sus principales funcionalidades son:
- Guardar comentarios: La función
saveComment
envía el nuevo comentario al servidor. Mientras se realiza la petición, el texto del botón se actualiza a "Guardando..." para mostrar retroalimentación al usuario. Una vez completada la operación, se muestra un mensaje de éxito o error en un componentePopup
que se oculta automáticamente después de 3 segundos. Si el comentario se guarda correctamente, se añade inmediatamente a la lista de comentarios. - Cargar comentarios: Al llegar al final de la lista, se activa la carga de más comentarios mediante un mecanismo similar al utilizado para los posts.
- Mostrar/ocultar la caja de comentarios: La caja para escribir comentarios solo es visible si el usuario ha iniciado sesión.
- Renderizar los comentarios: Se utiliza un bucle
each
para mostrar cada comentario en un componenteComment
reutilizable. - Indicadores de estado:
- Se muestra un
Spinner
mientras se cargan los comentarios. - Si no hay más comentarios para cargar o el post no tiene comentarios, se muestra un mensaje informativo.
- Se muestra un
Y eso es todo por este tutorial. Recordad que cualquier duda podéis escribirla en la caja de comentarios y también os dejo el enlace a la rama que corresponde a este tutorial por si tenéis cualquier problema:
https://github.com/albertorc87/blog-svelte-5/tree/tutorial-7-comentarios
Para finalizar el tutorial me hubiese gustado añadir un artículo más creando un panel de administración, pero he estado teniendo problemas buscando librerías para edición de texto compatibles con, Svelte 5
así que de momento lo dejaremos aquí y retomaremos el tutorial más adelante.
Espero que este post te ayude y como siempre, te recomiendo seguirme en Twitter para estar al tanto de los nuevo contenido. Ahora también puedes seguirme en Instagram donde estoy subiendo tips, tutoriales en vídeo e información sobre herramientas para developers.
Por último os dejo mi guía para aprender a trabajar con APIs donde explico todo el funcionamiento de una API, el protocolo HTTP y veremos como construir una API con arquitectura REST.
Nos leemos 👋.