logo cosasdedevs
Parte 2: 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



My Profile
Ene 28, 2025

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:

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 componente Popup 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 componente Comment 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.

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 👋.

🐍 Sígueme en Twitter

Si te gusta el contenido que subo y no quieres perderte nada, sígueme en Twitter y te avisaré cada vez que cree contenido nuevo 💪
Luego ¡Te sigo!

Nos tomamos en serio tu privacidad

Utilizamos cookies propias y de terceros para recopilar y analizar datos sobre la interacción de los usuarios con cosasdedevs.com. Ver política de cookies.