diff --git a/prisma/migrations/20250721133142_add_forum_posts_categories_comments_likes/migration.sql b/prisma/migrations/20250721133142_add_forum_posts_categories_comments_likes/migration.sql new file mode 100644 index 00000000..2fbc0e7b --- /dev/null +++ b/prisma/migrations/20250721133142_add_forum_posts_categories_comments_likes/migration.sql @@ -0,0 +1,121 @@ +-- CreateTable +CREATE TABLE "Post" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "isPinned" BOOLEAN NOT NULL DEFAULT false, + "isLocked" BOOLEAN NOT NULL DEFAULT false, + "authorId" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostCategory" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "color" TEXT NOT NULL, + "icon" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PostCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostComment" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "parentCommentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PostComment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostLike" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PostLike_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostCommentLike" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "commentId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PostCommentLike_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Post_categoryId_idx" ON "Post"("categoryId"); + +-- CreateIndex +CREATE INDEX "Post_createdAt_idx" ON "Post"("createdAt"); + +-- CreateIndex +CREATE INDEX "Post_isPinned_idx" ON "Post"("isPinned"); + +-- CreateIndex +CREATE UNIQUE INDEX "PostCategory_name_key" ON "PostCategory"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "PostCategory_slug_key" ON "PostCategory"("slug"); + +-- CreateIndex +CREATE INDEX "PostComment_postId_idx" ON "PostComment"("postId"); + +-- CreateIndex +CREATE INDEX "PostComment_authorId_idx" ON "PostComment"("authorId"); + +-- CreateIndex +CREATE INDEX "PostLike_postId_idx" ON "PostLike"("postId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PostLike_userId_postId_key" ON "PostLike"("userId", "postId"); + +-- CreateIndex +CREATE INDEX "PostCommentLike_commentId_idx" ON "PostCommentLike"("commentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PostCommentLike_userId_commentId_key" ON "PostCommentLike"("userId", "commentId"); + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "PostCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "PostComment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostLike" ADD CONSTRAINT "PostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostLike" ADD CONSTRAINT "PostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostCommentLike" ADD CONSTRAINT "PostCommentLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostCommentLike" ADD CONSTRAINT "PostCommentLike_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "PostComment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 614c7fe0..700994d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,10 @@ model User { languages UserLanguage[] comments Comment[] likes Like[] + posts Post[] + postComments PostComment[] + postLikes PostLike[] + postCommentLikes PostCommentLike[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -143,3 +147,92 @@ model JobOffers { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Post { + id String @id @default(cuid()) + title String + content String + isPinned Boolean @default(false) + isLocked Boolean @default(false) + + author User @relation(fields: [authorId], references: [id]) + authorId String + + category PostCategory @relation(fields: [categoryId], references: [id]) + categoryId String + + comments PostComment[] + likes PostLike[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([categoryId]) + @@index([createdAt]) + @@index([isPinned]) +} + +model PostCategory { + id String @id @default(cuid()) + name String @unique + description String + color String // Color para el badge (ej: "bg-blue-500") + icon String // Emoji o icono + slug String @unique + + posts Post[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PostComment { + id String @id @default(cuid()) + content String + + author User @relation(fields: [authorId], references: [id]) + authorId String + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String + + parentComment PostComment? @relation("PostCommentToPostComment", fields: [parentCommentId], references: [id], onDelete: Cascade) + parentCommentId String? + replies PostComment[] @relation("PostCommentToPostComment") + + likes PostCommentLike[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([postId]) + @@index([authorId]) +} + +model PostLike { + id String @id @default(cuid()) + userId String + postId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([userId, postId]) + @@index([postId]) +} + +model PostCommentLike { + id String @id @default(cuid()) + userId String + commentId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + comment PostComment @relation(fields: [commentId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([userId, commentId]) + @@index([commentId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 61fbe864..ec51588a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -317,6 +317,194 @@ async function main() { }), ); + // Create forum categories + const categories = await Promise.all([ + prisma.postCategory.upsert({ + where: { slug: 'desarrollo-web' }, + update: {}, + create: { + name: 'Desarrollo Web', + description: 'Frontend, Backend, Full Stack', + color: 'bg-blue-500', + icon: '💻', + slug: 'desarrollo-web', + }, + }), + prisma.postCategory.upsert({ + where: { slug: 'moviles' }, + update: {}, + create: { + name: 'Mobile', + description: 'React Native, Flutter, Swift, Kotlin', + color: 'bg-green-500', + icon: '📱', + slug: 'moviles', + }, + }), + prisma.postCategory.upsert({ + where: { slug: 'devops' }, + update: {}, + create: { + name: 'DevOps', + description: 'Docker, Kubernetes, CI/CD', + color: 'bg-purple-500', + icon: '⚙️', + slug: 'devops', + }, + }), + prisma.postCategory.upsert({ + where: { slug: 'bases-de-datos' }, + update: {}, + create: { + name: 'Bases de Datos', + description: 'SQL, NoSQL, ORMs', + color: 'bg-orange-500', + icon: '🗄️', + slug: 'bases-de-datos', + }, + }), + prisma.postCategory.upsert({ + where: { slug: 'consejos-carrera' }, + update: {}, + create: { + name: 'Consejos de Carrera', + description: 'Entrevistas, CV, primeros trabajos', + color: 'bg-pink-500', + icon: '🚀', + slug: 'consejos-carrera', + }, + }), + ]); + + // Create forum posts + const forumPosts = [ + { + title: '¿Cuál es la mejor forma de aprender React en 2025?', + content: `Hola comunidad! + +Soy nuevo en el desarrollo frontend y me gustaría aprender React de la manera más efectiva. He visto muchos recursos online pero no sé por dónde empezar. + +¿Qué recomiendan? ¿Cursos, documentación oficial, proyectos prácticos? + +¡Gracias por sus consejos!`, + authorEmail: 'user@example.com', + categorySlug: 'desarrollo-web', + isPinned: true, + }, + { + title: 'Mi experiencia trabajando remotamente como dev junior', + content: `Quería compartir mi experiencia trabajando remotamente como desarrollador junior durante estos últimos 6 meses. + +**Lo bueno:** +- Flexibilidad de horarios +- Ahorro en transporte y comida +- Mejor balance vida-trabajo + +**Los desafíos:** +- Comunicación con el equipo +- Distracciones en casa +- Feeling de aislamiento a veces + +¿Qué tal han sido sus experiencias? ¿Algún consejo para mejorar la productividad en casa?`, + authorEmail: 'maria.garcia@example.com', + categorySlug: 'consejos-carrera', + isPinned: false, + }, + { + title: 'Docker compose para desarrollo local - Tips y trucos', + content: `Después de meses trabajando con Docker en desarrollo, quería compartir algunos tips que me han ahorrado mucho tiempo: + +## 1. Volúmenes para desarrollo +\`\`\`yaml +volumes: + - .:/app + - /app/node_modules +\`\`\` + +## 2. Variables de entorno +Usar archivos \`.env\` para diferentes ambientes. + +## 3. Hot reload +Configurar correctamente el hot reload para no tener que rebuilder constantemente. + +¿Qué otros tips agregarían?`, + authorEmail: 'juan.perez@example.com', + categorySlug: 'devops', + isPinned: false, + }, + { + title: '¿PostgreSQL vs MongoDB para un proyecto nuevo?', + content: `Estoy empezando un nuevo proyecto y tengo que decidir entre PostgreSQL y MongoDB. + +**Contexto del proyecto:** +- Aplicación web con usuarios +- Manejo de posts y comentarios +- Necesito búsquedas complejas +- Equipo pequeño (2-3 devs) + +¿Qué recomiendan y por qué? ¿Hay algún factor decisivo que debería considerar?`, + authorEmail: 'ana.lopez@example.com', + categorySlug: 'bases-de-datos', + isPinned: false, + }, + ]; + + const createdPosts = await Promise.all( + forumPosts.map(async (postData) => { + const author = users.find((u) => u.email === postData.authorEmail)!; + const category = categories.find((c) => c.slug === postData.categorySlug)!; + + return prisma.post.create({ + data: { + title: postData.title, + content: postData.content, + isPinned: postData.isPinned, + authorId: author.id, + categoryId: category.id, + }, + }); + }), + ); + + // Add some comments to posts + await Promise.all( + createdPosts.map(async (post) => { + // Add 2-5 comments per post + const numComments = Math.floor(Math.random() * 4) + 2; + + for (let i = 0; i < numComments; i++) { + const randomAuthor = users[Math.floor(Math.random() * users.length)]; + + await prisma.postComment.create({ + data: { + content: `Excelente post! Muy útil la información que compartiste. ${i === 0 ? 'Gracias por tomarte el tiempo de escribir esto.' : `Comentario ${i + 1} sobre este tema.`}`, + authorId: randomAuthor.id, + postId: post.id, + }, + }); + } + }), + ); + + // Add some likes to posts + await Promise.all( + createdPosts.map(async (post) => { + for (const user of users) { + // Skip if the user is the author of the post + if (user.id === post.authorId) continue; + // 50% chance to like each post + if (Math.random() < 0.5) { + await prisma.postLike.create({ + data: { + userId: user.id, + postId: post.id, + }, + }); + } + } + }), + ); + console.log(`Seed data created successfully!`); } diff --git a/src/actions/forum_posts/create.ts b/src/actions/forum_posts/create.ts new file mode 100644 index 00000000..52029a72 --- /dev/null +++ b/src/actions/forum_posts/create.ts @@ -0,0 +1,100 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import * as z from 'zod'; + +const createPostSchema = z.object({ + title: z.string().min(1, 'El título es requerido'), + content: z.string().min(1, 'El contenido es requerido'), + categoryId: z.string().min(1, 'La categoría es requerida'), +}); + +export async function createPost(data: z.infer) { + try { + // Validar datos de entrada + const validatedData = createPostSchema.parse(data); + + // Obtener el ID del usuario desde las cookies/sesión + const cookieStore = await cookies(); + const sessionId = cookieStore.get('sessionId')?.value; + + if (!sessionId) { + throw new Error('No hay sesión activa'); + } + + // Buscar la sesión para obtener el userId + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + + if (!session) { + throw new Error('Sesión inválida'); + } + + // Verificar que la categoría existe + const category = await prisma.postCategory.findUnique({ + where: { id: validatedData.categoryId }, + }); + + if (!category) { + throw new Error('La categoría seleccionada no existe'); + } + + // Crear el post + const post = await prisma.post.create({ + data: { + title: validatedData.title, + content: validatedData.content, + authorId: session.userId, + categoryId: validatedData.categoryId, + }, + include: { + author: { + select: { + id: true, + name: true, + image: true, + }, + }, + category: { + select: { + name: true, + color: true, + icon: true, + slug: true, + }, + }, + _count: { + select: { + comments: true, + likes: true, + }, + }, + }, + }); + + // Revalidar las páginas del foro + revalidatePath('/foro'); + revalidatePath(`/foro/categoria/${category.slug}`); + + return { + success: true, + post, + }; + } catch (error) { + console.error('Error creating post:', error); + + if (error instanceof z.ZodError) { + throw new Error('Datos de entrada inválidos'); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error('Error interno del servidor'); + } +} diff --git a/src/actions/forum_posts/delete.ts b/src/actions/forum_posts/delete.ts new file mode 100644 index 00000000..d4b025b3 --- /dev/null +++ b/src/actions/forum_posts/delete.ts @@ -0,0 +1,63 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; + +export async function deletePost(postId: string) { + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get('sessionId')?.value; + + if (!sessionId) { + throw new Error('No hay sesión activa'); + } + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + + if (!session) { + throw new Error('Sesión inválida'); + } + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { + authorId: true, + category: { + select: { slug: true }, + }, + }, + }); + + if (!post) { + throw new Error('El post no existe'); + } + + if (post.authorId !== session.userId) { + throw new Error('No tienes permisos para eliminar este post'); + } + + await prisma.post.delete({ + where: { id: postId }, + }); + + revalidatePath('/foro'); + revalidatePath(`/foro/categoria/${post.category.slug}`); + + return { + success: true, + message: 'Post eliminado exitosamente', + }; + } catch (error) { + console.error('Error deleting post:', error); + + if (error instanceof Error) { + throw error; + } + + throw new Error('Error interno del servidor'); + } +} diff --git a/src/actions/forum_posts/get-categories.ts b/src/actions/forum_posts/get-categories.ts new file mode 100644 index 00000000..5fd08757 --- /dev/null +++ b/src/actions/forum_posts/get-categories.ts @@ -0,0 +1,26 @@ +'use server'; + +import prisma from '@/lib/prisma'; + +export async function getPostCategories() { + try { + const categories = await prisma.postCategory.findMany({ + select: { + id: true, + name: true, + description: true, + color: true, + icon: true, + slug: true, + }, + orderBy: { + name: 'asc', + }, + }); + + return categories; + } catch (error) { + console.error('Error fetching post categories:', error); + throw new Error('Error al obtener las categorías'); + } +} diff --git a/src/actions/forum_posts/get-for-edit.ts b/src/actions/forum_posts/get-for-edit.ts new file mode 100644 index 00000000..964ed53e --- /dev/null +++ b/src/actions/forum_posts/get-for-edit.ts @@ -0,0 +1,62 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { cookies } from 'next/headers'; + +export async function getPostForEdit(postId: string) { + try { + const cookieStore = await cookies(); + const sessionId = cookieStore.get('sessionId')?.value; + + if (!sessionId) { + throw new Error('No hay sesión activa'); + } + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + + if (!session) { + throw new Error('Sesión inválida'); + } + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { + id: true, + title: true, + content: true, + categoryId: true, + authorId: true, + category: { + select: { + name: true, + slug: true, + }, + }, + }, + }); + + if (!post) { + throw new Error('El post no existe'); + } + + if (post.authorId !== session.userId) { + throw new Error('No tienes permisos para editar este post'); + } + + return { + success: true, + post, + }; + } catch (error) { + console.error('Error fetching post for edit:', error); + + if (error instanceof Error) { + throw error; + } + + throw new Error('Error interno del servidor'); + } +} diff --git a/src/actions/forum_posts/update.ts b/src/actions/forum_posts/update.ts new file mode 100644 index 00000000..53257e85 --- /dev/null +++ b/src/actions/forum_posts/update.ts @@ -0,0 +1,114 @@ +'use server'; + +import prisma from '@/lib/prisma'; +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import * as z from 'zod'; + +const updatePostSchema = z.object({ + title: z.string().min(1, 'El título es requerido'), + content: z.string().min(1, 'El contenido es requerido'), + categoryId: z.string().min(1, 'La categoría es requerida'), +}); + +export async function updatePost(postId: string, data: z.infer) { + try { + const validatedData = updatePostSchema.parse(data); + + const cookieStore = await cookies(); + const sessionId = cookieStore.get('sessionId')?.value; + + if (!sessionId) { + throw new Error('No hay sesión activa'); + } + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { userId: true }, + }); + + if (!session) { + throw new Error('Sesión inválida'); + } + + const existingPost = await prisma.post.findUnique({ + where: { id: postId }, + select: { + authorId: true, + category: { + select: { slug: true } + } + }, + }); + + if (!existingPost) { + throw new Error('El post no existe'); + } + + if (existingPost.authorId !== session.userId) { + throw new Error('No tienes permisos para editar este post'); + } + + const category = await prisma.postCategory.findUnique({ + where: { id: validatedData.categoryId }, + }); + + if (!category) { + throw new Error('La categoría seleccionada no existe'); + } + + const updatedPost = await prisma.post.update({ + where: { id: postId }, + data: { + title: validatedData.title, + content: validatedData.content, + categoryId: validatedData.categoryId, + }, + include: { + author: { + select: { + id: true, + name: true, + image: true, + }, + }, + category: { + select: { + name: true, + color: true, + icon: true, + slug: true, + }, + }, + _count: { + select: { + comments: true, + likes: true, + }, + }, + }, + }); + + revalidatePath('/foro'); + revalidatePath(`/foro/categoria/${existingPost.category.slug}`); + revalidatePath(`/foro/categoria/${category.slug}`); + revalidatePath(`/foro/tema/${postId}`); + + return { + success: true, + post: updatedPost, + }; + } catch (error) { + console.error('Error updating post:', error); + + if (error instanceof z.ZodError) { + throw new Error('Datos de entrada inválidos'); + } + + if (error instanceof Error) { + throw error; + } + + throw new Error('Error interno del servidor'); + } +} diff --git a/src/app/(platform)/foro/categoria/[id]/page.tsx b/src/app/(platform)/foro/categoria/[id]/page.tsx new file mode 100644 index 00000000..9cd1d902 --- /dev/null +++ b/src/app/(platform)/foro/categoria/[id]/page.tsx @@ -0,0 +1,282 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Input } from '@/components/ui/input'; +import { Heading2 } from '@/components/ui/heading-2'; +import { ArrowLeft, Search, Pin, Heart, MessageCircle, Filter, SortDesc, Plus } from 'lucide-react'; +import Link from 'next/link'; +import prisma from '@/lib/prisma'; +import { formatDistanceToNow } from 'date-fns'; +import { es } from 'date-fns/locale'; +import { notFound } from 'next/navigation'; + +const CategoryPage = async ({ params }: { params: { id: string } }) => { + const category = await prisma.postCategory.findUnique({ + where: { slug: params.id }, + include: { + _count: { + select: { posts: true }, + }, + }, + }); + + if (!category) { + notFound(); + } + + const posts = await prisma.post.findMany({ + where: { categoryId: category.id }, + orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }], + include: { + author: true, + _count: { + select: { + comments: true, + likes: true, + }, + }, + }, + }); + + // Estadísticas de la categoría + const totalPosts = posts.length; + const totalComments = await prisma.postComment.count({ + where: { + post: { + categoryId: category.id, + }, + }, + }); + return ( +
+
+ + + Volver al foro + +
+ +
+
+
+ {category.icon} +
+
+ {category.name} +

{category.description}

+
+ +
+ +
+ + +
{totalPosts}
+
Temas
+
+
+ + +
{totalComments}
+
Mensajes
+
+
+ + +
{category._count.posts}
+
Total Posts
+
+
+
+
+ +
+ {/* Main Content */} + +
+ {/* SearchBar y Filtros */} + + + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Lista de posts */} + + +
+ Posts recientes + {posts.length} Posts +
+
+ + {posts.map((post) => ( + + + +
+ + + {post.author.name[0]} + + +
+
+ {post.isPinned && } +

{post.title}

+
+ +

+ {post.content.substring(0, 200)}... +

+ +
+ {post.author.name} + + + {formatDistanceToNow(post.createdAt, { addSuffix: true, locale: es })} + +
+ +
+
+
+ + {post._count.comments} +
+
+ + {post._count.likes} +
+
+
+
+
+
+
+ + ))} +
+
+
+ + {/* Sidebar-Derecho */} + +
+ + + Acciones + + + + + + + {/* Reglas de Categorias */} + + + + Reglas de la categoría + + +
    +
  • • Mantén las discusiones relacionadas con {category.name.toLowerCase()}
  • +
  • • Incluye código relevante cuando sea posible
  • +
  • • Sé específico en los títulos de tus posts
  • +
  • • Busca antes de crear un tema duplicado
  • +
+
+
+ + + + Moderadores activos + + + {[ + { name: 'Agustín Sánchez', avatar: '/agus.webp', status: 'online' }, + { name: 'German Navarro', avatar: '/german.webp', status: 'online' }, + { name: 'Mauricio Chaile', avatar: '/mauri.webp', status: 'away' }, + ].map((moderator) => ( +
+
+ + + {moderator.name[0]} + +
+
+
+
{moderator.name}
+
+ {moderator.status === 'online' ? 'En línea' : 'Ausente'} +
+
+
+ ))} + + + + + + Categorías relacionadas + + + {[ + { name: 'Móviles', icon: '📱', threads: 28 }, + { name: 'DevOps', icon: '⚙️', threads: 19 }, + { name: 'Bases de Datos', icon: '🗄️', threads: 22 }, + ].map((category) => ( + +
+
+ {category.icon} + {category.name} +
+ {category.threads} +
+ + ))} +
+
+
+
+
+ ); +}; + +export default CategoryPage; diff --git a/src/app/(platform)/foro/forum-data.ts b/src/app/(platform)/foro/forum-data.ts new file mode 100644 index 00000000..8ac931f7 --- /dev/null +++ b/src/app/(platform)/foro/forum-data.ts @@ -0,0 +1,85 @@ +import prisma from '@/lib/prisma'; +import { User } from '@prisma/client'; +import { cookies } from 'next/headers'; + +export interface ForumData { + totalPosts: number; + totalUsers: number; + categories: Array<{ + id: string; + name: string; + description: string; + slug: string; + icon: string; + color: string; + _count: { posts: number }; + }>; + recentPosts: Array<{ + id: string; + title: string; + isPinned: boolean; + createdAt: Date; + author: User; + category: { + id: string; + name: string; + }; + _count: { + comments: number; + likes: number; + }; + }>; + currentUser: User | null; +} + +export async function getForumData(): Promise { + const sessionId = cookies().get('sessionId')?.value; + + let currentUser: User | null = null; + + if (sessionId) { + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + include: { user: true }, + }); + + if (session) { + currentUser = session.user; + } + } + + const [totalPosts, totalUsers, categories, recentPosts] = await Promise.all([ + prisma.post.count(), + prisma.user.count(), + prisma.postCategory.findMany({ + include: { + _count: { + select: { posts: true }, + }, + }, + orderBy: { name: 'asc' }, + }), + prisma.post.findMany({ + take: 5, + orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }], + include: { + author: true, + category: true, + _count: { + select: { + comments: true, + likes: true, + }, + }, + }, + }), + ]); + + return { + totalPosts, + totalUsers, + categories, + recentPosts, + currentUser, + }; +} diff --git a/src/app/(platform)/foro/nuevo-post/crear-post.tsx b/src/app/(platform)/foro/nuevo-post/crear-post.tsx new file mode 100644 index 00000000..56b3589a --- /dev/null +++ b/src/app/(platform)/foro/nuevo-post/crear-post.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import * as z from 'zod'; +import { ChevronLeft, FileText } from 'lucide-react'; +import { renderMarkdown } from '@/lib/render-markdown'; +import { Heading1 } from '@/components/ui/typography'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { getPostCategories } from '@/actions/forum_posts/get-categories'; +import { createPost } from '@/actions/forum_posts/create'; +import { useRouter } from 'next/navigation'; + +const formSchema = z.object({ + title: z.string().min(1, 'Title is required'), + content: z.string().min(1, 'Content is required'), + categoryId: z.string().min(1, 'Category is required'), +}); + +type PostCategory = { + id: string; + name: string; + description: string; + color: string; + icon: string; + slug: string; +}; + +export function CreatePostForm() { + const [categories, setCategories] = useState([]); + const [isLoadingCategories, setIsLoadingCategories] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: '', + content: '', + categoryId: '', + }, + }); + + useEffect(() => { + const loadCategories = async () => { + try { + const fetchedCategories = await getPostCategories(); + setCategories(fetchedCategories); + } catch (error) { + console.error('Error loading categories:', error); + toast.error('Error al cargar las categorías'); + } finally { + setIsLoadingCategories(false); + } + }; + + loadCategories(); + }, []); + + const onSubmit = async (data: z.infer) => { + try { + setIsSubmitting(true); + + const result = await createPost(data); + + if (result.success) { + toast.success('¡Post creado exitosamente! 🎉'); + router.push(`/foro/tema/${result.post.id}`); + } + } catch (error) { + console.error('Error creating post:', error); + toast.error(error instanceof Error ? error.message : 'Error al crear el post'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ {/* Panel Izquierdo - Form */} + +
+
+ + + + Crear Post +
+ +
+
+ +
+ ( + + Título + + + + + + )} + /> + + ( + + Categoría + + + + )} + /> + + ( + + Contenido (Formato Markdown) + +