Parte 1: Crear comentarios en nuestro blog de Svelte 5 y Sveltkit y optimizaciones
En esta primera parte del tutorial vamos a añadir unas mejoras para tener un código más limpio y organizado y además crearemos nuevos componentes y funciones que usaremos en la segunda parte de este tutorial para trabajar con los comentarios.
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
Renombrar rutas
Antes de continuar con los comentarios, vamos a renombrar la carpeta src\routes\api\blog
a src\routes\api\posts
para que el nombre sea más coherente. Realizamos este cambio porque, siguiendo las convenciones, las rutas suelen nombrarse en plural para reflejar colecciones de datos. Además, considero que "posts" describe mejor el propósito de esta ruta que "blog".
Recuerda que también deberás editar la petición a la API que realizamos en src\routes\+page.svelte
para que apunte a nueva ruta.
Archivo para Gestionar Comentarios
Siguiendo el mismo enfoque que utilizamos para gestionar los posts y los usuarios, crearemos un archivo dedicado para manejar las operaciones relacionadas con los comentarios. Este archivo se llamará src\lib\server\db\db-comments.ts
y contendrá todas las funciones necesarias para interactuar con la tabla de comentarios.
Una vez que hayas creado el archivo, añade el siguiente contenido:
import { desc, eq, type InferSelectModel } from "drizzle-orm";
import { db } from "./db";
import { commentsTable, usersTable } from "./schema";
export type CommentWithUser = Pick<
InferSelectModel<typeof commentsTable>,
'content' | 'createdAt'
> & {
username: string;
avatar: string | null;
};
export async function getCommentsByPostId(idPost: number, limit: number = 5, offset: number = 0): Promise<CommentWithUser[]> {
return await db
.select({
content: commentsTable.content,
createdAt: commentsTable.createdAt,
username: usersTable.username,
avatar: usersTable.avatar
})
.from(commentsTable)
.innerJoin(usersTable, eq(commentsTable.userId, usersTable.id))
.where(eq(commentsTable.postId, idPost))
.orderBy(desc(commentsTable.createdAt))
.limit(limit)
.offset(offset);
}
export async function createComment(content: string, postId: number, userId: number): Promise<CommentWithUser | null> {
const comment: typeof commentsTable.$inferInsert = {
content,
postId,
userId,
};
const commentId = await db.insert(commentsTable).values(comment).returning({ insertedId: commentsTable.id });
return await getCommentById(commentId[0].insertedId);
}
export async function getCommentById(commentId: number): Promise<CommentWithUser | null> {
const result = await db
.select({
content: commentsTable.content,
createdAt: commentsTable.createdAt,
username: usersTable.username,
avatar: usersTable.avatar
})
.from(commentsTable)
.innerJoin(usersTable, eq(commentsTable.userId, usersTable.id))
.where(eq(commentsTable.id, commentId));
return result[0] || null;
}
En este archivo definimos una interfaz que describe los datos que queremos recuperar de un comentario. Estos incluyen el contenido, la fecha de creación, y además, el usuario asociado junto con su avatar.
Las funciones que implementamos son las siguientes:
getCommentsByPostId
: Devuelve los comentarios de un post de forma paginada, facilitando el manejo de grandes cantidades de datos.createComment
: Se encarga de guardar un nuevo comentario en la tabla de comentarios.getCommentById
: Recupera la información de un comentario específico utilizando su identificador. Esta función es utilizada dentro decreateComment
para obtener los datos del comentario recién creado, permitiendo mostrarlo de inmediato en el artículo correspondiente.
Crear endpoint para guardar y obtener comentarios por post
Ahora vamos a crear el archivo src\routes\api\posts\[id]\comments\+server.ts
que se encargará de retornar los comentarios de un artículo y también nos dará la opción de crear un comentario. Para ello, creamos el archivo y añadimos el siguiente contenido:
import { json, type RequestHandler } from '@sveltejs/kit';
import { createComment, getCommentsByPostId } from '$lib/server/db/db-comments';
export const POST: RequestHandler = async ({ request, locals, params }) => {
if (!locals.user?.id) {
return json(
{ error: 'Debes estar autenticado para poder escribir un comentario' },
{ status: 401 }
);
}
const data = await request.json();
if (!data?.content) {
return json(
{ error: 'Debes estar autenticado para poder escribir un comentario' },
{ status: 400 }
);
}
if (typeof data.content !== 'string') {
return json(
{ error: 'Valor inválido para un comentario' },
{ status: 400 }
);
}
if (data.content.length < 1 || data.content.length > 1000) {
return json(
{ error: 'Un comentario debe tener entre 1 y 1000 caracteres' },
{ status: 400 }
);
}
const comment = await createComment(data.content, Number(params.id), locals.user.id);
return json({...comment});
};
export const GET: RequestHandler = async ({ url, params }) => {
const offset: number = Number(url.searchParams.get('offset') || 0);
const idPost: number = Number(params.id);
if (typeof offset !== 'number' || offset < 0) {
return json(
{ error: 'Offset must be a number and greater than or equal to 0' },
{ status: 400 }
);
}
const comments = await getCommentsByPostId(idPost, 10, offset);
return json({comments});
};
En este archivo vamos a gestionar dos métodos. POST para crear los comentarios y GET para recuperarlos. El identificador del post lo recuperaremos mediante el parámetro de ruta que creamos [id]
. Además, en la creación nos aseguramos que el usuario está autenticado y si no es así, no permitimos la creación. No me explayo mucho más en esta parte porque son cosas que ya hemos visto anteriormente.
Modificación en la API de recuperar posts
En el archivo src\routes\api\posts\+server.ts
vamos a realizar dos modificaciones. La primera es cambiar la importación de import type { RequestHandler } from './$types';
por type { RequestHandler } from '@sveltejs/kit';
. Al haber creado una carpeta al mismo nivel que el archivo +server.ts ocurre un error porque en SvelteKit las rutas y sus archivos asociados dependen de la estructura de carpetas dentro de la carpeta src/routes
. Cuando añades más carpetas, la ruta relativa al archivo $types
cambia y genera el error.
El siguiente cambio que vamos a hacer es que la petición ahora va a ser de tipo GET en vez POST que es lo que tiene sentido y el parámetro offset lo enviaremos y lo recuperaremos como un parámetro de consulta:
import { json, type RequestHandler } from '@sveltejs/kit';
import { getPosts } from '$lib/server/db/db-posts';
import { getFirstParagraph } from '$lib/utils/html';
export const GET: RequestHandler = async ({ url }) => {
const offset: number = Number(url.searchParams.get('offset') || 0);
if (typeof offset !== 'number' || offset < 0) {
return json(
{ error: 'Offset must be a number and greater than or equal to 0' },
{ status: 400 }
);
}
const posts = await getPosts(5, offset);
return json({
posts: posts.map((post) => {
post.content = getFirstParagraph(post.content);
return post;
})
});
};
Crear componente Snipper
En la página principal, al llegar al final, se mostraba un spinner indicando que se estaban cargando más posts. Dado que este mismo comportamiento será necesario para los comentarios, vamos a reutilizar el código creando un componente compartido.
Para ello, crearemos un archivo llamado src\lib\components\Spinner.svelte
y añadiremos el siguiente contenido:
<div class="loading">
<div class="spinner"></div>
</div>
<style>
.loading {
text-align: center;
padding: 1rem;
}
.spinner {
width: 30px;
height: 30px;
border: 4px solid #ccc;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
Como siempre, recuerda añadir la exportación del componente al archivo src\lib\components\index.ts
.
Ahora vamos al archivo src\routes\+page.svelte
y borramos el código que hemos migrado a este componente y lo añadimos:
import { ArticleMain, Snipper } from '$components';
...
{#each posts as post}
<ArticleMain {post} />
{/each}
{#if isLoading}
<Snipper />
{/if}
<div id="load-more" class="load-more-posts"></div>
...
Crear nueva utilidad para recuperar datos de la API
Otra de las optimizaciones que vamos a implementar es crear un archivo llamado src\lib\utils\fecth-api-data.ts
que se encargará de realizar las peticiones GET
paginadas a la API, ya que va a ser muy similar el proceso tanto para recuperar artículos como para recuperar comentarios. Creamos el archivo y añadimos el siguiente código:
export function loadPaginateData(offset: number, url: string): Promise<Response> {
return new Promise((resolve, reject) => {
setTimeout(async () => {
try {
const response = await fetch(`${url}?offset=${offset}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
});
if (response.ok) {
resolve(response);
} else {
reject(new Error(`Failed with status: ${response.status}`));
}
} catch (err) {
reject(err);
}
}, 2000);
});
}
Esta función recibirá el offset y la URL
a la que realizar la petición y seguirá simulando los dos segundos de espera para que se pueda apreciar el snipper
.
Para usar la nueva función, debemos ir al archivo src\routes\+page.svelte
y realizar la siguiente modificación:
import { loadPaginateData } from '$lib/utils/fecth-api-data';
import { page } from '$app/stores';
...
async function loadMorePosts() {
if (!hasMorePosts || isLoading) {
return;
}
isLoading = true;
offset += 5;
try {
const response = await loadPaginateData(offset, `${$page.url.origin}/api/posts`);
if (response.status === 200) {
const responsePost = await response.json();
let newPosts: PostWithUser[] = responsePost.posts;
newPosts = newPosts.map((post) => {
post.createdAt = new Date(post.createdAt);
return post;
});
if (newPosts.length === 0) {
hasMorePosts = false;
} else {
posts = [...posts, ...newPosts];
if (newPosts.length < 5) {
hasMorePosts = 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;
}
Hemos realizado una mejora utilizando $pages
de Svelte
para construir la ruta absoluta de manera dinámica. Esto nos ayuda a evitar errores si cambiamos la ubicación desde donde realizamos la petición. Además, ahora empleamos la función loadPaginateData
en lugar de fetchPosts
, ya que esta última queda obsoleta en nuestro flujo. Puedes eliminar fetchPosts
del código, ya que no será necesaria a partir de este cambio.
Componente Popup para mostrar notificaciones
Vamos a realizar una mejora importante reemplazando el componente src\lib\components\MessageError.svelte
por uno nuevo llamado Popup
. Este nuevo componente será más versátil, ya que podremos utilizarlo tanto para mensajes de error como de éxito.
Además, considero que esta solución es más visualmente atractiva y mejora la experiencia de usuario. En particular, no me convencía cómo se mostraban los mensajes de error en el login, ya que en pantallas pequeñas el mensaje quedaba abajo y podía pasar desapercibido para algunos usuarios.
Por ahora, utilizaremos este nuevo componente en los comentarios para observar su funcionamiento. Más adelante, te dejo como tarea implementarlo también en el login.
Crea el archivo src\lib\components\Popup.svelte
y añade el siguiente contenido:
<script lang="ts">
import type { Snippet } from 'svelte';
export type TypePopup = 'success' | 'error';
interface PopupProps {
children: Snippet;
type: TypePopup;
visible: boolean;
}
let { children, type, visible }: PopupProps = $props();
const closePopup = () => {
visible = false;
};
</script>
<div class="popup {type}" class:show={visible}>
{@render children()}
<button class="close-btn" onclick={closePopup}>×</button>
</div>
<style>
.popup {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 1rem 2rem;
border-radius: 8px;
color: #fff;
font-size: 1rem;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
opacity: 0;
visibility: hidden;
transition:
opacity 0.3s,
visibility 0.3s;
z-index: 1000;
}
.popup.success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.popup.error {
border: 1px solid #f5c2c7;
background-color: #f8d7da;
color: #842029;
}
.popup.show {
opacity: 1;
visibility: visible;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: transparent;
border: none;
color: #fff;
font-size: 1.2rem;
cursor: pointer;
}
.close-btn:hover {
color: #ddd;
}
</style>
Este archivo define un tipo llamado TypePopup
que especifica los dos tipos de mensajes que vamos a permitir: "éxito" y "error". El componente recibe las siguientes propiedades:
children
: el contenido o mensaje que se mostrará.type
: el tipo de mensaje (éxito o error).visible
: una propiedad que indica si el componente debe estar visible o no.
Desde el componente padre podemos controlar su visibilidad, y además, el propio componente incluye un botón que permite cerrarlo manualmente. Finalmente, se estructura la parte HTML y se aplican los estilos correspondientes para mostrar un mensaje de error o de éxito.
Como siempre, añade la exportación del componente al archivo src\lib\components\index.ts
.
Pulsa en el siguiente enlace para continuar con la segunda parte de este tutorial:
https://cosasdedevs.com/posts/parte-2-crear-comentarios-blog-svelte-5-sveltkit-optimizaciones/
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 👋.